Files
service-finder/frontend_admin/pages/persons/index.vue
2026-06-29 14:11:15 +00:00

541 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>