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

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