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

@@ -2351,11 +2351,13 @@ var garages$1 = {
title: "Garázsok CRM",
subtitle: "Garázsok listája, csomagkezelés és státuszmódosítás",
total_garages: "Összes Garázs",
active_garages: "Aktív Garázsok",
premium_garages: "Premium Garázsok",
enterprise_garages: "Enterprise Garázsok",
private_garages: "Privát Garázsok",
corporate_garages: "Céges Garázsok",
search_placeholder: "Keresés név, kapcsolattartó vagy e-mail alapján...",
all_tiers: "Összes csomag",
all_types: "Összes típus",
type_individual: "Privát (Egyéni)",
type_corporate: "Céges (Vállalati)",
company_name: "Cégnév",
email: "E-mail",
status: "Státusz",
@@ -2411,9 +2413,51 @@ var garages$1 = {
contact_name: "Kapcsolattartó neve",
contact_role: "Szerepkör",
contact_department: "Osztály",
contact_phone: "Telefonszám",
contact_phone: "Kapcsolattartó telefon",
no_contact: "Nincs elsődleges kapcsolattartó",
coming_soon: "Ez a funkció hamarosan elérhető lesz..."
edit_details: "Adatok Szerkesztése",
save: "Mentés",
generalTab: "Általános",
employeesTab: "Dolgozók",
fleetTab: "Flotta",
analyticsTab: "Analitika",
editBasicTab: "Alapadatok",
editContactTab: "Kapcsolat",
editAddressTab: "Címek",
zip: "Irányítószám",
city: "Város",
street_name: "Utca",
street_type: "Utca típusa",
house_number: "Házszám",
coming_soon: "Ez a funkció hamarosan elérhető lesz...",
missing_data_title: "Hiányzó adatok",
missing_data_desc: "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
missing_data_activation_required: "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
missing_tax_number: "Adószám",
missing_billing: "Számlázási cím",
missing_contact_email: "Kapcsolattartó e-mail",
missing_contact_phone: "Kapcsolattartó telefon",
missing_data_count: "{count} mező hiányzik",
contact_person_name: "Kapcsolattartó neve",
contact_email: "Kapcsolattartó e-mail",
billing_address: "Számlázási cím",
billing_zip: "Irányítószám",
billing_city: "Város",
billing_street_name: "Utca",
billing_street_type: "Utca típusa",
billing_house_number: "Házszám",
notification_address: "Értesítési cím",
notification_zip: "Irányítószám",
notification_city: "Város",
notification_street_name: "Utca",
notification_street_type: "Utca típusa",
notification_house_number: "Házszám",
tab_basic: "Alapadatok",
tab_contact: "Kapcsolat",
tab_addresses: "Címek",
basic_info: "Alapadatok",
contact_section: "Kapcsolattartó",
addresses_section: "Címek"
},
employees: {
title: "Dolgozók",
@@ -2427,13 +2471,43 @@ var garages$1 = {
remove: "Eltávolítás",
no_employees: "Még nincsenek tagok ehhez a garázshoz.",
edit_role_title: "Tag szerepkörének szerkesztése",
editRoleTitle: "Tag szerepkörének szerkesztése",
editRoleFor: "{name} szerepkörének módosítása",
cancel: "Mégse",
save: "Mentés",
saveRole: "Szerepkör mentése",
saving: "Mentés...",
status_active: "Aktív",
status_inactive: "Inaktív",
remove_title: "Tag eltávolítása",
removeTitle: "Tag eltávolítása",
remove_confirm: "Biztosan eltávolítod {name} tagot a garázsból?",
remove_btn: "Eltávolítás"
removeConfirm: "Biztosan eltávolítod {name} tagot a garázsból?",
remove_btn: "Eltávolítás",
removing: "Eltávolítás...",
add_employee: "Új dolgozó hozzáadása",
addTitle: "Új dolgozó hozzáadása",
email: "E-mail cím",
emailPlaceholder: "Add meg az e-mail címet...",
roleMember: "Tag",
roleManager: "Menedzser",
roleAdmin: "Admin",
roleOwner: "Tulajdonos",
add: "Hozzáadás",
adding: "Hozzáadás..."
},
fleet: {
title: "Flotta",
total_vehicles: "Összesen {count} jármű",
plate: "Rendszám",
brand: "Márka",
model: "Modell",
brand_model: "Márka & Modell",
year: "Évjárat",
status: "Státusz",
vin: "Alvázszám",
no_vehicles: "Ehhez a garázshoz még nincsenek járművek rendelve.",
no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez."
}
};
const locale_hu_46json_ee06c965 = {
@@ -2541,11 +2615,13 @@ var garages = {
title: "Garages CRM",
subtitle: "Garage list, package management and status changes",
total_garages: "Total Garages",
active_garages: "Active Garages",
premium_garages: "Premium Garages",
enterprise_garages: "Enterprise Garages",
private_garages: "Private Garages",
corporate_garages: "Corporate Garages",
search_placeholder: "Search by name, contact or email...",
all_tiers: "All packages",
all_types: "All types",
type_individual: "Private (Individual)",
type_corporate: "Corporate",
company_name: "Company Name",
email: "Email",
status: "Status",
@@ -2601,9 +2677,51 @@ var garages = {
contact_name: "Contact Name",
contact_role: "Role",
contact_department: "Department",
contact_phone: "Phone",
contact_phone: "Contact Phone",
no_contact: "No primary contact person",
coming_soon: "This feature will be available soon..."
edit_details: "Edit Details",
save: "Save",
generalTab: "General",
employeesTab: "Employees",
fleetTab: "Fleet",
analyticsTab: "Analytics",
editBasicTab: "Basic Info",
editContactTab: "Contact",
editAddressTab: "Addresses",
zip: "ZIP Code",
city: "City",
street_name: "Street",
street_type: "Street Type",
house_number: "House Number",
coming_soon: "This feature will be available soon...",
missing_data_title: "Missing Data",
missing_data_desc: "The garage profile is incomplete. Please fill in the missing fields.",
missing_data_activation_required: "To activate the garage, the following data must be provided:",
missing_tax_number: "Tax Number",
missing_billing: "Billing Address",
missing_contact_email: "Contact Email",
missing_contact_phone: "Contact Phone",
missing_data_count: "{count} fields missing",
contact_person_name: "Contact Person Name",
contact_email: "Contact Email",
billing_address: "Billing Address",
billing_zip: "ZIP Code",
billing_city: "City",
billing_street_name: "Street",
billing_street_type: "Street Type",
billing_house_number: "House Number",
notification_address: "Notification Address",
notification_zip: "ZIP Code",
notification_city: "City",
notification_street_name: "Street",
notification_street_type: "Street Type",
notification_house_number: "House Number",
tab_basic: "Basic Info",
tab_contact: "Contact",
tab_addresses: "Addresses",
basic_info: "Basic Info",
contact_section: "Contact Person",
addresses_section: "Addresses"
},
employees: {
title: "Employees",
@@ -2617,13 +2735,43 @@ var garages = {
remove: "Remove",
no_employees: "No employees assigned to this garage.",
edit_role_title: "Edit Employee Role",
editRoleTitle: "Edit Employee Role",
editRoleFor: "Change role for {name}",
cancel: "Cancel",
save: "Save Changes",
saveRole: "Save Role",
saving: "Saving...",
status_active: "Active",
status_inactive: "Inactive",
remove_title: "Remove Employee",
removeTitle: "Remove Employee",
remove_confirm: "Are you sure you want to remove \"{name}\" from this garage?",
remove_btn: "Remove"
removeConfirm: "Are you sure you want to remove \"{name}\" from this garage?",
remove_btn: "Remove",
removing: "Removing...",
add_employee: "Add Employee",
addTitle: "Add New Employee",
email: "Email Address",
emailPlaceholder: "Enter email address...",
roleMember: "Member",
roleManager: "Manager",
roleAdmin: "Admin",
roleOwner: "Owner",
add: "Add Employee",
adding: "Adding..."
},
fleet: {
title: "Fleet",
total_vehicles: "{count} vehicles total",
plate: "Plate",
brand: "Brand",
model: "Model",
brand_model: "Brand & Model",
year: "Year",
status: "Status",
vin: "VIN",
no_vehicles: "No vehicles assigned to this garage yet.",
no_fleet_access: "You don't have permission to view fleet data."
}
};
const locale_en_46json_0b63539c = {
@@ -3278,16 +3426,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
const assets = {
"/index.mjs": {
"type": "text/javascript; charset=utf-8",
"etag": "\"23532-gRN4chechZSOn9PdQdfcKj3v+Ek\"",
"mtime": "2026-06-25T19:56:11.463Z",
"size": 144690,
"etag": "\"24b7a-X7Gi3Oi5IkHbqD0x6I8saqEhvJo\"",
"mtime": "2026-06-26T00:29:32.171Z",
"size": 150394,
"path": "index.mjs"
},
"/index.mjs.map": {
"type": "application/json",
"etag": "\"83172-ssEX0StaUacfa43NRfqyNY4W7FM\"",
"mtime": "2026-06-25T19:56:11.464Z",
"size": 536946,
"etag": "\"83219-k/zre5TDT2FcQBXF7JC+SUbYHHs\"",
"mtime": "2026-06-26T00:29:32.171Z",
"size": 537113,
"path": "index.mjs.map"
}
};

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"date": "2026-06-25T19:56:47.228Z",
"date": "2026-06-26T00:29:33.690Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
@@ -11,7 +11,7 @@
"dev": {
"pid": 19,
"workerAddress": {
"socketPath": "\u0000nitro-worker-19-19-19-6745.sock"
"socketPath": "\u0000nitro-worker-19-37-37-3295.sock"
}
}
}

View File

@@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/25/2026, 7:56:46 PM
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/26/2026, 1:03:38 AM
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";

View File

@@ -29,6 +29,7 @@ type NuxtAppInjections =
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/i18n.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/warn.dev.server.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/check-if-layout-used.js")> &
InjectionType<typeof import("../../plugins/api")> &
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/ssg-detect.js")>
declare module '#app' {

View File

@@ -98,11 +98,13 @@
"title": "Garages CRM",
"subtitle": "Garage list, package management and status changes",
"total_garages": "Total Garages",
"active_garages": "Active Garages",
"premium_garages": "Premium Garages",
"enterprise_garages": "Enterprise Garages",
"private_garages": "Private Garages",
"corporate_garages": "Corporate Garages",
"search_placeholder": "Search by name, contact or email...",
"all_tiers": "All packages",
"all_types": "All types",
"type_individual": "Private (Individual)",
"type_corporate": "Corporate",
"company_name": "Company Name",
"email": "Email",
"status": "Status",
@@ -160,7 +162,50 @@
"contact_department": "Department",
"contact_phone": "Phone",
"no_contact": "No primary contact person",
"coming_soon": "This feature will be available soon..."
"edit_details": "Edit Details",
"save": "Save",
"generalTab": "General",
"employeesTab": "Employees",
"fleetTab": "Fleet",
"analyticsTab": "Analytics",
"editBasicTab": "Basic Info",
"editContactTab": "Contact",
"editAddressTab": "Addresses",
"zip": "ZIP Code",
"city": "City",
"street_name": "Street",
"street_type": "Street Type",
"house_number": "House Number",
"coming_soon": "This feature will be available soon...",
"missing_data_title": "Missing Data",
"missing_data_desc": "The garage profile is incomplete. Please fill in the missing fields.",
"missing_data_activation_required": "To activate the garage, the following data must be provided:",
"missing_tax_number": "Tax Number",
"missing_billing": "Billing Address",
"missing_contact_email": "Contact Email",
"missing_contact_phone": "Contact Phone",
"missing_data_count": "{count} fields missing",
"contact_person_name": "Contact Person Name",
"contact_email": "Contact Email",
"contact_phone": "Contact Phone",
"billing_address": "Billing Address",
"billing_zip": "ZIP Code",
"billing_city": "City",
"billing_street_name": "Street",
"billing_street_type": "Street Type",
"billing_house_number": "House Number",
"notification_address": "Notification Address",
"notification_zip": "ZIP Code",
"notification_city": "City",
"notification_street_name": "Street",
"notification_street_type": "Street Type",
"notification_house_number": "House Number",
"tab_basic": "Basic Info",
"tab_contact": "Contact",
"tab_addresses": "Addresses",
"basic_info": "Basic Info",
"contact_section": "Contact Person",
"addresses_section": "Addresses"
},
"employees": {
"title": "Employees",
@@ -174,13 +219,43 @@
"remove": "Remove",
"no_employees": "No employees assigned to this garage.",
"edit_role_title": "Edit Employee Role",
"editRoleTitle": "Edit Employee Role",
"editRoleFor": "Change role for {name}",
"cancel": "Cancel",
"save": "Save Changes",
"saveRole": "Save Role",
"saving": "Saving...",
"status_active": "Active",
"status_inactive": "Inactive",
"remove_title": "Remove Employee",
"removeTitle": "Remove Employee",
"remove_confirm": "Are you sure you want to remove \"{name}\" from this garage?",
"remove_btn": "Remove"
"removeConfirm": "Are you sure you want to remove \"{name}\" from this garage?",
"remove_btn": "Remove",
"removing": "Removing...",
"add_employee": "Add Employee",
"addTitle": "Add New Employee",
"email": "Email Address",
"emailPlaceholder": "Enter email address...",
"roleMember": "Member",
"roleManager": "Manager",
"roleAdmin": "Admin",
"roleOwner": "Owner",
"add": "Add Employee",
"adding": "Adding..."
},
"fleet": {
"title": "Fleet",
"total_vehicles": "{count} vehicles total",
"plate": "Plate",
"brand": "Brand",
"model": "Model",
"brand_model": "Brand & Model",
"year": "Year",
"status": "Status",
"vin": "VIN",
"no_vehicles": "No vehicles assigned to this garage yet.",
"no_fleet_access": "You don't have permission to view fleet data."
}
}
}

View File

@@ -98,11 +98,13 @@
"title": "Garázsok CRM",
"subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás",
"total_garages": "Összes Garázs",
"active_garages": "Aktív Garázsok",
"premium_garages": "Premium Garázsok",
"enterprise_garages": "Enterprise Garázsok",
"private_garages": "Privát Garázsok",
"corporate_garages": "Céges Garázsok",
"search_placeholder": "Keresés név, kapcsolattartó vagy e-mail alapján...",
"all_tiers": "Összes csomag",
"all_types": "Összes típus",
"type_individual": "Privát (Egyéni)",
"type_corporate": "Céges (Vállalati)",
"company_name": "Cégnév",
"email": "E-mail",
"status": "Státusz",
@@ -160,7 +162,50 @@
"contact_department": "Osztály",
"contact_phone": "Telefonszám",
"no_contact": "Nincs elsődleges kapcsolattartó",
"coming_soon": "Ez a funkció hamarosan elérhető lesz..."
"edit_details": "Adatok Szerkesztése",
"save": "Mentés",
"generalTab": "Általános",
"employeesTab": "Dolgozók",
"fleetTab": "Flotta",
"analyticsTab": "Analitika",
"editBasicTab": "Alapadatok",
"editContactTab": "Kapcsolat",
"editAddressTab": "Címek",
"zip": "Irányítószám",
"city": "Város",
"street_name": "Utca",
"street_type": "Utca típusa",
"house_number": "Házszám",
"coming_soon": "Ez a funkció hamarosan elérhető lesz...",
"missing_data_title": "Hiányzó adatok",
"missing_data_desc": "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
"missing_data_activation_required": "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
"missing_tax_number": "Adószám",
"missing_billing": "Számlázási cím",
"missing_contact_email": "Kapcsolattartó e-mail",
"missing_contact_phone": "Kapcsolattartó telefon",
"missing_data_count": "{count} mező hiányzik",
"contact_person_name": "Kapcsolattartó neve",
"contact_email": "Kapcsolattartó e-mail",
"contact_phone": "Kapcsolattartó telefon",
"billing_address": "Számlázási cím",
"billing_zip": "Irányítószám",
"billing_city": "Város",
"billing_street_name": "Utca",
"billing_street_type": "Utca típusa",
"billing_house_number": "Házszám",
"notification_address": "Értesítési cím",
"notification_zip": "Irányítószám",
"notification_city": "Város",
"notification_street_name": "Utca",
"notification_street_type": "Utca típusa",
"notification_house_number": "Házszám",
"tab_basic": "Alapadatok",
"tab_contact": "Kapcsolat",
"tab_addresses": "Címek",
"basic_info": "Alapadatok",
"contact_section": "Kapcsolattartó",
"addresses_section": "Címek"
},
"employees": {
"title": "Dolgozók",
@@ -174,13 +219,43 @@
"remove": "Eltávolítás",
"no_employees": "Még nincsenek tagok ehhez a garázshoz.",
"edit_role_title": "Tag szerepkörének szerkesztése",
"editRoleTitle": "Tag szerepkörének szerkesztése",
"editRoleFor": "{name} szerepkörének módosítása",
"cancel": "Mégse",
"save": "Mentés",
"saveRole": "Szerepkör mentése",
"saving": "Mentés...",
"status_active": "Aktív",
"status_inactive": "Inaktív",
"remove_title": "Tag eltávolítása",
"removeTitle": "Tag eltávolítása",
"remove_confirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"remove_btn": "Eltávolítás"
"removeConfirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"remove_btn": "Eltávolítás",
"removing": "Eltávolítás...",
"add_employee": "Új dolgozó hozzáadása",
"addTitle": "Új dolgozó hozzáadása",
"email": "E-mail cím",
"emailPlaceholder": "Add meg az e-mail címet...",
"roleMember": "Tag",
"roleManager": "Menedzser",
"roleAdmin": "Admin",
"roleOwner": "Tulajdonos",
"add": "Hozzáadás",
"adding": "Hozzáadás..."
},
"fleet": {
"title": "Flotta",
"total_vehicles": "Összesen {count} jármű",
"plate": "Rendszám",
"brand": "Márka",
"model": "Modell",
"brand_model": "Márka & Modell",
"year": "Évjárat",
"status": "Státusz",
"vin": "Alvázszám",
"no_vehicles": "Ehhez a garázshoz még nincsenek járművek rendelve.",
"no_fleet_access": "Nincs jogosultságod a flotta adatok megtekintéséhez."
}
}
}

View File

@@ -340,7 +340,7 @@ const menuGroups: MenuGroup[] = [
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
children: [
{
path: '/users',
path: '/', /* P0 HOTFIX: placeholder until /users page is built */
label: 'Felhasználók listája',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
},
@@ -371,7 +371,7 @@ const menuGroups: MenuGroup[] = [
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
children: [
{
path: '/logs',
path: '/', /* P0 HOTFIX: placeholder until /logs page is built */
label: 'Rendszernaplók',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
},

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,43 @@
<h1 class="text-2xl font-bold text-white">{{ $t('garages.title') }}</h1>
<p class="text-slate-400 mt-1">{{ $t('garages.subtitle') }}</p>
</div>
<!-- Search & Filter Bar (always rendered stable DOM for input focus) -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('garages.search_placeholder')"
class="w-full pl-10 pr-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition"
/>
</div>
<div class="flex items-center gap-2">
<select
v-model="filterType"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('garages.all_types') }}</option>
<option value="individual">{{ $t('garages.type_individual') }}</option>
<option value="corporate">{{ $t('garages.type_corporate') }}</option>
</select>
<select
v-model="selectedPackage"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('garages.all_tiers') }}</option>
<option
v-for="tier in uniqueTiers"
:key="tier"
:value="tier"
>
{{ tier }}
</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
@@ -30,8 +67,8 @@
<!-- Content -->
<template v-else>
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- P0: Dynamic Stats Cards - based on real org_type data, not fake subscription tiers -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
@@ -49,12 +86,12 @@
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.active_garages') }}</p>
<p class="text-2xl font-bold text-emerald-400">{{ activeCount }}</p>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.private_garages') }}</p>
<p class="text-2xl font-bold text-emerald-400">{{ individualCount }}</p>
</div>
</div>
</div>
@@ -62,54 +99,15 @@
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.premium_garages') }}</p>
<p class="text-2xl font-bold text-amber-400">{{ premiumCount }}</p>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.corporate_garages') }}</p>
<p class="text-2xl font-bold text-amber-400">{{ corporateCount }}</p>
</div>
</div>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-purple-500/10 text-purple-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.enterprise_garages') }}</p>
<p class="text-2xl font-bold text-purple-400">{{ enterpriseCount }}</p>
</div>
</div>
</div>
</div>
<!-- Search & Filter Bar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('garages.search_placeholder')"
class="w-full pl-10 pr-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition"
/>
</div>
<div class="flex items-center gap-2">
<select
v-model="filterTier"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('garages.all_tiers') }}</option>
<option value="free">Free</option>
<option value="premium">Premium</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
</div>
<!-- Data Table -->
@@ -347,7 +345,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
definePageMeta({
middleware: 'auth',
@@ -401,7 +399,8 @@ const error = ref<string | null>(null)
const garages = ref<Garage[]>([])
const availableTiers = ref<Tier[]>([])
const searchQuery = ref('')
const filterTier = ref('all')
const filterType = ref('all')
const selectedPackage = ref('all')
// Modal state
const showSubscriptionModal = ref(false)
@@ -414,45 +413,62 @@ const saveError = ref<string | null>(null)
// Notification
const saveNotification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Debounced Search Timer ──────────────────────────────────────────
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
/**
* P0: Debounced search — waits 400ms after the user stops typing
* before triggering a server-side API call. This prevents excessive
* requests on every keystroke while keeping the UI responsive.
*/
function debouncedFetchGarages() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
fetchGarages()
}, 400)
}
// ── Watchers ────────────────────────────────────────────────────────
// Watch searchQuery with debounce — only fires API call after 400ms idle
watch(searchQuery, () => {
debouncedFetchGarages()
})
// Watch filterType — immediate API call (no debounce needed for dropdowns)
watch(filterType, () => {
fetchGarages()
})
// Watch selectedPackage — immediate API call
watch(selectedPackage, () => {
fetchGarages()
})
// ── Computed ───────────────────────────────────────────────────────
const activeCount = computed(() => garages.value.filter(g => g.is_active).length)
const premiumCount = computed(() => {
return garages.value.filter(g => {
const name = g.subscription_tier_name.toLowerCase()
return name.includes('premium') || name.includes('pro')
}).length
})
const enterpriseCount = computed(() => {
return garages.value.filter(g => {
const name = g.subscription_tier_name.toLowerCase()
return name.includes('enterprise') || name.includes('vip') || name.includes('corp')
}).length
// P0: Dynamic stats based on real org_type from the database
const individualCount = computed(() => garages.value.filter(g => g.org_type === 'individual').length)
const corporateCount = computed(() => garages.value.filter(g => g.org_type !== 'individual').length)
// P0: Extract unique subscription tier names for the filter dropdown
const uniqueTiers = computed(() => {
const tiers = new Set<string>()
for (const g of garages.value) {
if (g.subscription_tier_name) {
tiers.add(g.subscription_tier_name)
}
}
return Array.from(tiers).sort()
})
const filteredGarages = computed(() => {
return garages.value.filter(g => {
// Search filter — search across display_name, name, full_name, and city
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
const displayName = garageDisplayName(g).toLowerCase()
const matchesSearch =
displayName.includes(q) ||
g.full_name.toLowerCase().includes(q) ||
g.name.toLowerCase().includes(q) ||
(g.city && g.city.toLowerCase().includes(q))
if (!matchesSearch) return false
}
// Tier filter
if (filterTier.value !== 'all') {
const name = g.subscription_tier_name.toLowerCase()
if (filterTier.value === 'free' && !name.includes('free') && !name.includes('fallback')) return false
if (filterTier.value === 'premium' && !name.includes('premium') && !name.includes('pro')) return false
if (filterTier.value === 'enterprise' && !name.includes('enterprise') && !name.includes('vip') && !name.includes('corp')) return false
}
return true
})
})
// P0: Server-side filtered — the API handles all filtering.
// This computed is now a simple pass-through; the real filtering
// happens in fetchGarages() via query params.
const filteredGarages = computed(() => garages.value)
// ── Auth Helper ────────────────────────────────────────────────────
@@ -468,8 +484,26 @@ async function fetchGarages() {
loading.value = true
error.value = null
try {
// P0: Server-side search — pass all filter params to the API
const params: Record<string, any> = { limit: 200 }
// Debounced search query → server-side omni-search
if (searchQuery.value) {
params.search_term = searchQuery.value
}
// Org type filter
if (filterType.value !== 'all') {
params.org_type_filter = filterType.value
}
// Subscription tier filter
if (selectedPackage.value !== 'all') {
params.tier_name_filter = selectedPackage.value
}
const response = await $fetch<{ total: number; skip: number; limit: number; garages: Garage[] }>('/api/v1/admin/organizations', {
params: { limit: 200 },
params,
headers: getAuthHeaders(),
})
garages.value = response.garages
@@ -552,9 +586,28 @@ async function saveSubscription() {
// ── Status Toggle ──────────────────────────────────────────────────
async function toggleStatus(garage: Garage) {
// This would call a PATCH endpoint to toggle is_active
// For now, we just show a notification that this is not yet implemented
showNotification('error', `Status toggle for "${garageDisplayName(garage)}" not yet implemented via API`)
// Determine the new status based on current state
const newStatus = garage.is_active ? 'inactive' : 'active'
const actionLabel = garage.is_active ? 'deaktiválása' : 'aktiválása'
try {
await $fetch(`/api/v1/admin/organizations/${garage.id}/status`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: { status: newStatus },
})
showNotification('success', `"${garageDisplayName(garage)}" ${actionLabel} sikeresen megtörtént.`)
// Refresh the list to reflect changes
await fetchGarages()
} catch (err: any) {
console.error('[Garages] Failed to toggle status:', err)
showNotification('error', err?.data?.detail || err?.message || `A garázs ${actionLabel} sikertelen.`)
}
}
// ── Helpers ────────────────────────────────────────────────────────

View File

@@ -0,0 +1,54 @@
/**
* 🌐 Global API Plugin — 401 Unauthorized Interceptor
*
* This Nuxt plugin wraps the global `$fetch` to intercept 401 responses.
* When a 401 is detected, it:
* 1. Calls the auth store's logout() method (clears tokens)
* 2. Redirects to /login
*
* This fixes the P0 bug where expired tokens did not force a logout on page reload.
*/
import { defineNuxtPlugin, navigateTo, useCookie } from '#app'
import { useAuthStore } from '~/stores/auth'
export default defineNuxtPlugin(() => {
// We hook into the global $fetch via onResponse interceptor
// Nuxt's $fetch is built on ofetch which supports interceptors
const originalFetch = globalThis.fetch
// Override global fetch to intercept 401 responses
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init)
// Intercept 401 Unauthorized
if (response.status === 401) {
// Avoid intercepting login endpoint itself (would cause redirect loops)
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url)
if (url.includes('/auth/login') || url.includes('/auth/refresh')) {
return response
}
// Clear auth state
try {
const authStore = useAuthStore()
authStore.logout()
} catch {
// Fallback: manually clear the cookie
const tokenCookie = useCookie('access_token')
tokenCookie.value = null
}
// Redirect to login
if (process.client) {
// Use window.location for hard redirect to ensure full state reset
window.location.href = '/login'
} else {
await navigateTo('/login')
}
}
return response
}
})