admin_users_personels

This commit is contained in:
Roo
2026-06-29 14:11:15 +00:00
parent 383e5511b6
commit df4a0f07ff
237 changed files with 17849 additions and 2934 deletions

View File

@@ -0,0 +1,776 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ $t('users.title') }}</h1>
<p class="text-slate-400 mt-1">{{ $t('users.subtitle') }}</p>
</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('users.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 flex-wrap">
<!-- Status Filter -->
<select
v-model="statusFilter"
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('users.all_statuses') }}</option>
<option value="active">{{ $t('users.status_active') }}</option>
<option value="inactive">{{ $t('users.status_inactive') }}</option>
<option value="deleted">{{ $t('users.status_deleted') }}</option>
<option value="banned">{{ $t('users.status_banned') }}</option>
</select>
<!-- Role Filter -->
<select
v-model="roleFilter"
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('users.all_roles') }}</option>
<option value="user">{{ $t('users.role_user') }}</option>
<option value="admin">{{ $t('users.role_admin') }}</option>
<option value="staff">{{ $t('users.role_staff') }}</option>
<option value="superadmin">{{ $t('users.role_superadmin') }}</option>
</select>
<!-- Plan Filter -->
<select
v-model="planFilter"
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('users.all_plans') }}</option>
<option value="FREE">{{ $t('users.plan_free') }}</option>
<option value="PREMIUM">{{ $t('users.plan_premium') }}</option>
<option value="ENTERPRISE">{{ $t('users.plan_enterprise') }}</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="flex flex-col items-center gap-3">
<svg class="w-8 h-8 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p class="text-slate-400 text-sm">{{ $t('users.loading') }}</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center">
<svg class="w-12 h-12 text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-red-400 text-sm mb-2">{{ error }}</p>
<button @click="fetchUsers" class="px-4 py-2 text-sm bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition">
{{ $t('users.retry') }}
</button>
</div>
<!-- Content -->
<template v-else>
<!-- Stats Cards Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<!-- Total Users -->
<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">
<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 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.total_users') }}</p>
<p class="text-2xl font-bold text-white">{{ stats.total_users }}</p>
</div>
</div>
</div>
<!-- Active Users -->
<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-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" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.active_users') }}</p>
<p class="text-2xl font-bold text-emerald-400">{{ stats.active_users }}</p>
</div>
</div>
</div>
<!-- Deleted Users -->
<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-red-500/10 text-red-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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.deleted_users') }}</p>
<p class="text-2xl font-bold text-red-400">{{ stats.deleted_users }}</p>
</div>
</div>
</div>
<!-- Banned Users -->
<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-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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.banned_users') }}</p>
<p class="text-2xl font-bold text-amber-400">{{ stats.banned_users }}</p>
</div>
</div>
</div>
<!-- New Today -->
<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-cyan-500/10 text-cyan-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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.new_today') }}</p>
<p class="text-2xl font-bold text-cyan-400">{{ stats.new_users_today }}</p>
</div>
</div>
</div>
</div>
<!-- Registration Trend Chart -->
<div class="mb-8">
<UserTrendChart :data="stats.registration_trend" :loading="loading" />
</div>
<!-- Filtered results indicator -->
<div v-if="isFilterActive" class="mb-4 text-xs text-slate-500">
{{ $t('users.filtered_results') }}: <span class="text-slate-300 font-semibold">{{ users.length }}</span> / {{ totalCount }} db
</div>
<!-- Bulk Actions Bar -->
<div v-if="selectedUserIds.length > 0 && canEditUsers" class="mb-4 px-4 py-3 bg-indigo-900/30 border border-indigo-700/50 rounded-xl flex items-center justify-between">
<p class="text-sm text-indigo-300">
{{ $t('users.selected_count', { count: selectedUserIds.length }) }}
</p>
<div class="flex items-center gap-2">
<button
@click="bulkActivate"
class="px-3 py-1.5 text-xs font-medium bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 rounded-lg transition"
>
{{ $t('users.bulk_activate') }}
</button>
<button
@click="bulkDeactivate"
class="px-3 py-1.5 text-xs font-medium bg-red-600/20 text-red-300 hover:bg-red-600/30 rounded-lg transition"
>
{{ $t('users.bulk_deactivate') }}
</button>
<button
@click="clearSelection"
class="px-3 py-1.5 text-xs font-medium bg-slate-600/20 text-slate-300 hover:bg-slate-600/30 rounded-lg transition"
>
{{ $t('users.clear_selection') }}
</button>
</div>
</div>
<!-- Data Table -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700 bg-slate-800/80">
<th class="text-left py-3.5 px-4 w-10">
<input
type="checkbox"
:checked="allSelected"
@change="toggleSelectAll"
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
/>
</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">ID</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.email') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.name') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.role') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.status') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.package') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.registration') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.language') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-b border-slate-700/50 hover:bg-slate-700/30 transition"
>
<!-- Checkbox -->
<td class="py-3.5 px-4">
<input
type="checkbox"
:checked="selectedUserIds.includes(user.id)"
@change="toggleUserSelection(user.id)"
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
/>
</td>
<!-- ID -->
<td class="py-3.5 px-4 text-slate-400 font-mono text-xs">{{ user.id }}</td>
<!-- Email -->
<td class="py-3.5 px-4">
<NuxtLink
:to="'/users/' + user.id"
class="text-sm font-medium text-white hover:underline cursor-pointer hover:text-indigo-400 transition-colors"
>
{{ user.email }}
</NuxtLink>
</td>
<!-- Name -->
<td class="py-3.5 px-4">
<span class="text-slate-300">{{ user.person_name || '—' }}</span>
</td>
<!-- Role -->
<td class="py-3.5 px-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="roleBadgeClass(user.role)"
>
{{ roleLabel(user.role) }}
</span>
</td>
<!-- Status -->
<td class="py-3.5 px-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="statusBadgeClass(user)"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="statusDotClass(user)"
></span>
{{ statusLabel(user) }}
</span>
</td>
<!-- Package -->
<td class="py-3.5 px-4">
<span
class="px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="planBadgeClass(user.subscription_plan)"
>
{{ user.subscription_plan || '—' }}
</span>
</td>
<!-- Registration -->
<td class="py-3.5 px-4 text-slate-300 text-xs">
{{ formatDate(user.created_at) }}
</td>
<!-- Language -->
<td class="py-3.5 px-4">
<span class="text-slate-400 text-xs uppercase">{{ user.preferred_language || '—' }}</span>
</td>
<!-- Actions -->
<td class="py-3.5 px-4">
<div class="flex items-center gap-2">
<NuxtLink
:to="'/users/' + user.id"
class="px-3 py-1.5 text-xs font-medium bg-slate-600/20 text-slate-300 hover:bg-slate-600/30 rounded-lg transition"
>
{{ $t('users.view') }}
</NuxtLink>
<button
v-if="!user.is_deleted && canEditUsers"
@click="toggleUserActive(user)"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition"
:class="user.is_active
? 'bg-red-500/10 text-red-300 hover:bg-red-500/20'
: 'bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'"
>
{{ user.is_active ? $t('users.deactivate') : $t('users.activate') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div
v-if="users.length === 0 && !loading"
class="flex flex-col items-center justify-center py-16 text-center"
>
<svg class="w-12 h-12 text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-slate-400 text-sm">{{ $t('users.no_results') }}</p>
</div>
</div>
<!-- Pagination -->
<div v-if="totalCount > limit" class="flex items-center justify-between mt-6">
<p class="text-xs text-slate-500">
{{ $t('users.showing') }} {{ skip + 1 }}{{ Math.min(skip + limit, totalCount) }} / {{ totalCount }}
</p>
<div class="flex items-center gap-2">
<button
@click="prevPage"
:disabled="skip === 0"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="skip > 0 ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-800 text-slate-600'"
>
{{ $t('users.prev') }}
</button>
<button
@click="nextPage"
:disabled="skip + limit >= totalCount"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="skip + limit < totalCount ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-800 text-slate-600'"
>
{{ $t('users.next') }}
</button>
</div>
</div>
</template>
<!-- Notification -->
<div
v-if="notification"
class="fixed bottom-6 right-6 px-5 py-3 rounded-xl shadow-xl text-sm font-medium z-50"
:class="notification.type === 'success'
? 'bg-emerald-600 text-white'
: 'bg-red-600 text-white'"
>
{{ notification.message }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '~/stores/auth'
import UserTrendChart from '~/components/charts/UserTrendChart.vue'
const router = useRouter()
const auth = useAuthStore()
definePageMeta({
middleware: 'auth',
})
// ── RBAC Permission Check ──────────────────────────────────────────
const canViewUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:view']
})
const canEditUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:edit']
})
const canBanUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:ban']
})
const canDeleteUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:delete']
})
// Redirect if no view permission
if (!canViewUsers.value && !auth.isAdmin) {
router.push('/')
}
// ── Types ──────────────────────────────────────────────────────────
interface UserListItem {
id: number
email: string
role: string
is_active: boolean
is_deleted: boolean
is_vip: boolean
subscription_plan: string
preferred_language: string
region_code: string
created_at: string | null
person_name: string | null
person_phone: string | null
}
interface UserListResponse {
total: number
skip: number
limit: number
users: UserListItem[]
}
interface UserStats {
total_users: number
active_users: number
deleted_users: number
banned_users: number
new_users_today: number
new_users_this_week: number
new_users_this_month: number
users_by_role: Record<string, number>
users_by_plan: Record<string, number>
users_by_language: Record<string, number>
users_with_person: number
users_without_person: number
registration_trend: { date: string; count: number }[]
active_organizations_count: number
total_memberships: number
}
// ── State ──────────────────────────────────────────────────────────
const loading = ref(true)
const error = ref<string | null>(null)
const users = ref<UserListItem[]>([])
const totalCount = ref(0)
const skip = ref(0)
const limit = ref(50)
const searchQuery = ref('')
const statusFilter = ref('all')
const roleFilter = ref('all')
const planFilter = ref('all')
const stats = ref<UserStats>({
total_users: 0,
active_users: 0,
deleted_users: 0,
banned_users: 0,
new_users_today: 0,
new_users_this_week: 0,
new_users_this_month: 0,
users_by_role: {},
users_by_plan: {},
users_by_language: {},
users_with_person: 0,
users_without_person: 0,
registration_trend: [],
active_organizations_count: 0,
total_memberships: 0,
})
// Bulk selection
const selectedUserIds = ref<number[]>([])
// Notification
const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Debounced Search Timer ──────────────────────────────────────────
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedFetchUsers() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
skip.value = 0
fetchUsers()
}, 400)
}
// ── Watchers ────────────────────────────────────────────────────────
watch(searchQuery, () => {
debouncedFetchUsers()
})
watch(statusFilter, () => {
skip.value = 0
fetchUsers()
})
watch(roleFilter, () => {
skip.value = 0
fetchUsers()
})
watch(planFilter, () => {
skip.value = 0
fetchUsers()
})
// ── Computed ───────────────────────────────────────────────────────
const isFilterActive = computed(() => {
return searchQuery.value !== '' || statusFilter.value !== 'all' || roleFilter.value !== 'all' || planFilter.value !== 'all'
})
const allSelected = computed(() => {
return users.value.length > 0 && selectedUserIds.value.length === users.value.length
})
// ── Auth Helper ────────────────────────────────────────────────────
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
// ── Data Fetching ──────────────────────────────────────────────────
async function fetchUsers() {
loading.value = true
error.value = null
try {
const params: Record<string, any> = { skip: skip.value, limit: limit.value }
if (searchQuery.value) {
params.search_term = searchQuery.value
}
// Status filter mapping: frontend uses combined status_filter, backend uses is_active/is_deleted
if (statusFilter.value !== 'all') {
switch (statusFilter.value) {
case 'active':
params.is_active = true
break
case 'inactive':
params.is_active = false
break
case 'deleted':
params.is_deleted = true
break
case 'banned':
params.is_active = false
break
}
}
if (roleFilter.value !== 'all') {
params.role = roleFilter.value
}
if (planFilter.value !== 'all') {
params.plan_filter = planFilter.value
}
const response = await $fetch<UserListResponse>('/api/v1/admin/users', {
params,
headers: getAuthHeaders(),
})
users.value = response.users
totalCount.value = response.total
} catch (err: any) {
console.error('[Users] Failed to fetch users:', err)
error.value = err?.data?.detail || err?.message || 'Failed to load users'
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const response = await $fetch<UserStats>('/api/v1/admin/users/stats', {
headers: getAuthHeaders(),
})
stats.value = response
} catch (err: any) {
console.error('[Users] Failed to fetch stats:', err)
}
}
// ── Pagination ─────────────────────────────────────────────────────
function nextPage() {
if (skip.value + limit.value < totalCount.value) {
skip.value += limit.value
fetchUsers()
}
}
function prevPage() {
if (skip.value > 0) {
skip.value = Math.max(0, skip.value - limit.value)
fetchUsers()
}
}
// ── Bulk Selection ─────────────────────────────────────────────────
function toggleSelectAll() {
if (allSelected.value) {
selectedUserIds.value = []
} else {
selectedUserIds.value = users.value.map(u => u.id)
}
}
function toggleUserSelection(userId: number) {
const idx = selectedUserIds.value.indexOf(userId)
if (idx === -1) {
selectedUserIds.value.push(userId)
} else {
selectedUserIds.value.splice(idx, 1)
}
}
function clearSelection() {
selectedUserIds.value = []
}
async function bulkActivate() {
if (selectedUserIds.value.length === 0) return
try {
await $fetch('/api/v1/admin/users/bulk-action', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
user_ids: selectedUserIds.value,
action: 'activate',
},
})
showNotification('success', `${selectedUserIds.value.length} user(s) activated successfully`)
selectedUserIds.value = []
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Bulk activate failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Bulk activate failed')
}
}
async function bulkDeactivate() {
if (selectedUserIds.value.length === 0) return
try {
await $fetch('/api/v1/admin/users/bulk-action', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
user_ids: selectedUserIds.value,
action: 'deactivate',
},
})
showNotification('success', `${selectedUserIds.value.length} user(s) deactivated successfully`)
selectedUserIds.value = []
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Bulk deactivate failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Bulk deactivate failed')
}
}
// ── Single User Toggle ─────────────────────────────────────────────
async function toggleUserActive(user: UserListItem) {
const newActive = !user.is_active
try {
await $fetch(`/api/v1/admin/users/${user.id}`, {
method: 'PATCH',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: { is_active: newActive },
})
showNotification('success', `User "${user.email}" ${newActive ? 'activated' : 'deactivated'} successfully`)
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Toggle status failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Failed to toggle user status')
}
}
// ── Helpers ────────────────────────────────────────────────────────
function roleBadgeClass(role: string): string {
const r = role.toLowerCase()
if (r === 'superadmin') return 'bg-purple-500/20 text-purple-300'
if (r === 'admin') return 'bg-indigo-500/20 text-indigo-300'
if (r === 'staff') return 'bg-cyan-500/20 text-cyan-300'
return 'bg-slate-500/20 text-slate-300'
}
function roleLabel(role: string): string {
const r = role.toLowerCase()
if (r === 'superadmin') return 'Superadmin'
if (r === 'admin') return 'Admin'
if (r === 'staff') return 'Staff'
return 'User'
}
function statusBadgeClass(user: UserListItem): string {
if (user.is_deleted) return 'bg-red-500/10 text-red-400'
if (!user.is_active) return 'bg-amber-500/10 text-amber-400'
return 'bg-emerald-500/10 text-emerald-400'
}
function statusDotClass(user: UserListItem): string {
if (user.is_deleted) return 'bg-red-400'
if (!user.is_active) return 'bg-amber-400'
return 'bg-emerald-400'
}
function statusLabel(user: UserListItem): string {
if (user.is_deleted) return 'Deleted'
if (!user.is_active) return 'Inactive'
return 'Active'
}
function planBadgeClass(plan: string): string {
const p = (plan || '').toLowerCase()
if (p.includes('enterprise') || p.includes('vip')) return 'bg-purple-500/20 text-purple-300'
if (p.includes('premium') || p.includes('pro')) return 'bg-amber-500/20 text-amber-300'
return 'bg-slate-500/20 text-slate-300'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return dateStr
}
}
function showNotification(type: 'success' | 'error', message: string) {
notification.value = { type, message }
setTimeout(() => {
notification.value = null
}, 4000)
}
// ── Lifecycle ──────────────────────────────────────────────────────
onMounted(() => {
fetchUsers()
fetchStats()
})
</script>