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