541 lines
23 KiB
Vue
541 lines
23 KiB
Vue
<template>
|
||
<div>
|
||
<!-- Page Header -->
|
||
<div class="mb-8">
|
||
<h1 class="text-2xl font-bold text-white">{{ $t('persons.title') }}</h1>
|
||
<p class="text-slate-400 mt-1">{{ $t('persons.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('persons.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">
|
||
<!-- Ghost Filter -->
|
||
<select
|
||
v-model="ghostFilter"
|
||
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('persons.all_ghost_statuses') }}</option>
|
||
<option value="no">{{ $t('persons.ghost_no') }}</option>
|
||
<option value="yes">{{ $t('persons.ghost_yes') }}</option>
|
||
</select>
|
||
<!-- Active Filter -->
|
||
<select
|
||
v-model="activeFilter"
|
||
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('persons.all_active_statuses') }}</option>
|
||
<option value="active">{{ $t('persons.active_yes') }}</option>
|
||
<option value="inactive">{{ $t('persons.active_no') }}</option>
|
||
</select>
|
||
<!-- Has User Filter -->
|
||
<select
|
||
v-model="hasUserFilter"
|
||
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('persons.all_user_statuses') }}</option>
|
||
<option value="yes">{{ $t('persons.has_user_yes') }}</option>
|
||
<option value="no">{{ $t('persons.has_user_no') }}</option>
|
||
</select>
|
||
<!-- Merged Filter -->
|
||
<select
|
||
v-model="mergedFilter"
|
||
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('persons.all_merged_statuses') }}</option>
|
||
<option value="no">{{ $t('persons.merged_no') }}</option>
|
||
<option value="yes">{{ $t('persons.merged_yes') }}</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('persons.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="fetchPersons" class="px-4 py-2 text-sm bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition">
|
||
{{ $t('persons.retry') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<template v-else>
|
||
<!-- Stats Cards Row -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||
<!-- Total Persons -->
|
||
<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="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('persons.total_persons') }}</p>
|
||
<p class="text-2xl font-bold text-white">{{ totalCount }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Active Persons -->
|
||
<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('persons.active_persons') }}</p>
|
||
<p class="text-2xl font-bold text-emerald-400">{{ activeCount }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Ghost Persons -->
|
||
<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('persons.ghost_persons') }}</p>
|
||
<p class="text-2xl font-bold text-amber-400">{{ ghostCount }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Merged Persons -->
|
||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('persons.merged_persons') }}</p>
|
||
<p class="text-2xl font-bold text-red-400">{{ mergedCount }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filtered results indicator -->
|
||
<div v-if="isFilterActive" class="mb-4 text-xs text-slate-500">
|
||
{{ $t('persons.filtered_results') }}: <span class="text-slate-300 font-semibold">{{ persons.length }}</span> / {{ totalCount }} db
|
||
</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 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('persons.name') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.phone') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.birth_date') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.status') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.users_count') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.active_user') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.address') }}</th>
|
||
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('persons.actions') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="person in persons"
|
||
:key="person.id"
|
||
class="border-b border-slate-700/50 hover:bg-slate-700/30 transition"
|
||
>
|
||
<!-- ID -->
|
||
<td class="py-3.5 px-4 text-slate-400 font-mono text-xs">{{ person.id }}</td>
|
||
<!-- Name -->
|
||
<td class="py-3.5 px-4">
|
||
<NuxtLink
|
||
:to="'/persons/' + person.id"
|
||
class="text-sm font-medium text-white hover:underline cursor-pointer hover:text-indigo-400 transition-colors"
|
||
>
|
||
{{ person.last_name }} {{ person.first_name }}
|
||
</NuxtLink>
|
||
</td>
|
||
<!-- Phone -->
|
||
<td class="py-3.5 px-4 text-slate-300">{{ person.phone || '—' }}</td>
|
||
<!-- Birth Date -->
|
||
<td class="py-3.5 px-4 text-slate-300 text-xs">{{ formatDate(person.birth_date) }}</td>
|
||
<!-- Status -->
|
||
<td class="py-3.5 px-4">
|
||
<div class="flex flex-wrap gap-1">
|
||
<span
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="person.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-amber-500/10 text-amber-400'"
|
||
>
|
||
<span
|
||
class="w-1.5 h-1.5 rounded-full mr-1.5"
|
||
:class="person.is_active ? 'bg-emerald-400' : 'bg-amber-400'"
|
||
></span>
|
||
{{ person.is_active ? $t('persons.active') : $t('persons.inactive') }}
|
||
</span>
|
||
<span
|
||
v-if="person.is_ghost"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300"
|
||
>
|
||
{{ $t('persons.ghost') }}
|
||
</span>
|
||
<span
|
||
v-if="person.merged_into_id"
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-300"
|
||
>
|
||
{{ $t('persons.merged') }}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<!-- Users Count -->
|
||
<td class="py-3.5 px-4">
|
||
<span class="text-slate-300">{{ person.users_count }}</span>
|
||
</td>
|
||
<!-- Active User -->
|
||
<td class="py-3.5 px-4">
|
||
<div v-if="person.active_user">
|
||
<p class="text-slate-300 text-xs">{{ person.active_user.email }}</p>
|
||
<span
|
||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||
:class="roleBadgeClass(person.active_user.role)"
|
||
>
|
||
{{ person.active_user.role }}
|
||
</span>
|
||
</div>
|
||
<span v-else class="text-slate-500">—</span>
|
||
</td>
|
||
<!-- Address -->
|
||
<td class="py-3.5 px-4">
|
||
<span v-if="person.address" class="text-slate-300 text-xs">{{ person.address.full_address_text || (person.address.address_city + ', ' + person.address.address_street_name + ' ' + (person.address.address_house_number || '')) }}</span>
|
||
<span v-else class="text-slate-500">—</span>
|
||
</td>
|
||
<!-- Actions -->
|
||
<td class="py-3.5 px-4">
|
||
<div class="flex items-center gap-2">
|
||
<NuxtLink
|
||
:to="'/persons/' + person.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('persons.view') }}
|
||
</NuxtLink>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div
|
||
v-if="persons.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="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>
|
||
<p class="text-slate-400 text-sm">{{ $t('persons.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('persons.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('persons.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('persons.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'
|
||
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
|
||
definePageMeta({
|
||
middleware: 'auth',
|
||
})
|
||
|
||
// ── RBAC Permission Check ──────────────────────────────────────────
|
||
|
||
const canViewPersons = computed(() => {
|
||
if (auth.isAdmin) return true
|
||
return !!auth.user?.system_capabilities?.['persons:view']
|
||
})
|
||
|
||
const canEditPersons = computed(() => {
|
||
if (auth.isAdmin) return true
|
||
return !!auth.user?.system_capabilities?.['persons:edit']
|
||
})
|
||
|
||
const canMergePersons = computed(() => {
|
||
if (auth.isAdmin) return true
|
||
return !!auth.user?.system_capabilities?.['persons:merge']
|
||
})
|
||
|
||
// Redirect if no view permission
|
||
if (!canViewPersons.value && !auth.isAdmin) {
|
||
router.push('/')
|
||
}
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────
|
||
|
||
interface PersonListItemActiveUser {
|
||
id: number
|
||
email: string | null
|
||
role: string | null
|
||
}
|
||
|
||
interface PersonAddress {
|
||
address_zip: string | null
|
||
address_city: string | null
|
||
address_street_name: string | null
|
||
address_street_type: string | null
|
||
address_house_number: string | null
|
||
address_stairwell: string | null
|
||
address_floor: string | null
|
||
address_door: string | null
|
||
address_hrsz: string | null
|
||
full_address_text: string | null
|
||
latitude: number | null
|
||
longitude: number | null
|
||
}
|
||
|
||
interface PersonListItem {
|
||
id: number
|
||
last_name: string
|
||
first_name: string
|
||
phone: string | null
|
||
birth_date: string | null
|
||
is_ghost: boolean
|
||
is_active: boolean
|
||
merged_into_id: number | null
|
||
users_count: number
|
||
active_user: PersonListItemActiveUser | null
|
||
address: PersonAddress | null
|
||
}
|
||
|
||
interface PersonListResponse {
|
||
total: number
|
||
skip: number
|
||
limit: number
|
||
items: PersonListItem[]
|
||
}
|
||
|
||
// ── State ──────────────────────────────────────────────────────────
|
||
|
||
const loading = ref(true)
|
||
const error = ref<string | null>(null)
|
||
const persons = ref<PersonListItem[]>([])
|
||
const totalCount = ref(0)
|
||
const skip = ref(0)
|
||
const limit = ref(50)
|
||
|
||
const searchQuery = ref('')
|
||
const ghostFilter = ref('all')
|
||
const activeFilter = ref('all')
|
||
const hasUserFilter = ref('all')
|
||
const mergedFilter = ref('all')
|
||
|
||
// Notification
|
||
const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||
|
||
// ── Computed Stats ─────────────────────────────────────────────────
|
||
|
||
const activeCount = computed(() => persons.value.filter(p => p.is_active).length)
|
||
const ghostCount = computed(() => persons.value.filter(p => p.is_ghost).length)
|
||
const mergedCount = computed(() => persons.value.filter(p => p.merged_into_id).length)
|
||
|
||
// ── Debounced Search Timer ──────────────────────────────────────────
|
||
|
||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function debouncedFetchPersons() {
|
||
if (searchDebounceTimer) {
|
||
clearTimeout(searchDebounceTimer)
|
||
}
|
||
searchDebounceTimer = setTimeout(() => {
|
||
skip.value = 0
|
||
fetchPersons()
|
||
}, 400)
|
||
}
|
||
|
||
// ── Watchers ────────────────────────────────────────────────────────
|
||
|
||
watch(searchQuery, () => {
|
||
debouncedFetchPersons()
|
||
})
|
||
|
||
watch([ghostFilter, activeFilter, hasUserFilter, mergedFilter], () => {
|
||
skip.value = 0
|
||
fetchPersons()
|
||
})
|
||
|
||
// ── Computed ───────────────────────────────────────────────────────
|
||
|
||
const isFilterActive = computed(() => {
|
||
return searchQuery.value !== ''
|
||
|| ghostFilter.value !== 'all'
|
||
|| activeFilter.value !== 'all'
|
||
|| hasUserFilter.value !== 'all'
|
||
|| mergedFilter.value !== 'all'
|
||
})
|
||
|
||
// ── Auth Helper ────────────────────────────────────────────────────
|
||
|
||
function getAuthHeaders() {
|
||
const tokenCookie = useCookie('access_token')
|
||
const token = tokenCookie.value
|
||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||
}
|
||
|
||
// ── Data Fetching ──────────────────────────────────────────────────
|
||
|
||
async function fetchPersons() {
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const params: Record<string, any> = { skip: skip.value, limit: limit.value }
|
||
|
||
if (searchQuery.value) {
|
||
params.search = searchQuery.value
|
||
}
|
||
if (ghostFilter.value !== 'all') {
|
||
params.is_ghost = ghostFilter.value === 'yes'
|
||
}
|
||
if (activeFilter.value !== 'all') {
|
||
params.is_active = activeFilter.value === 'active'
|
||
}
|
||
if (hasUserFilter.value !== 'all') {
|
||
params.has_user = hasUserFilter.value === 'yes'
|
||
}
|
||
if (mergedFilter.value !== 'all') {
|
||
params.is_merged = mergedFilter.value === 'yes'
|
||
}
|
||
|
||
const response = await $fetch<PersonListResponse>('/api/v1/admin/persons', {
|
||
params,
|
||
headers: getAuthHeaders(),
|
||
})
|
||
persons.value = response.items
|
||
totalCount.value = response.total
|
||
} catch (err: any) {
|
||
console.error('[Persons] Failed to fetch persons:', err)
|
||
error.value = err?.data?.detail || err?.message || 'Failed to load persons'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ── Pagination ─────────────────────────────────────────────────────
|
||
|
||
function nextPage() {
|
||
if (skip.value + limit.value < totalCount.value) {
|
||
skip.value += limit.value
|
||
fetchPersons()
|
||
}
|
||
}
|
||
|
||
function prevPage() {
|
||
if (skip.value > 0) {
|
||
skip.value = Math.max(0, skip.value - limit.value)
|
||
fetchPersons()
|
||
}
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────
|
||
|
||
function roleBadgeClass(role: string | null): 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 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(() => {
|
||
fetchPersons()
|
||
})
|
||
</script>
|