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

1020 lines
51 KiB
Vue

<template>
<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.details.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="fetchPerson" class="px-4 py-2 text-sm bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition">
{{ $t('persons.details.retry') }}
</button>
</div>
<!-- Content -->
<template v-else-if="person">
<!-- Header -->
<div class="mb-8">
<button
@click="goBack"
class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ $t('persons.details.back') }}
</button>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center text-lg font-bold text-white"
:class="getInitialsColor(personDisplayName)"
>
{{ getInitials(personDisplayName) }}
</div>
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white">{{ personDisplayName }}</h1>
<span
class="inline-flex items-center px-3 py-1 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>
<span
v-if="person.is_sales_agent"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/20 text-amber-300"
>
{{ $t('persons.details.sales_agent') }}
</span>
</div>
<p class="text-sm text-slate-400 mt-0.5">
ID: {{ person.id }}
<span v-if="person.phone" class="ml-2 text-slate-500">· {{ person.phone }}</span>
</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
v-if="hasEditPermission"
@click="openEditModal"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition"
>
<svg class="w-4 h-4" 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>
{{ $t('persons.details.edit') }}
</button>
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-slate-700 mb-6">
<nav class="flex gap-6">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="pb-3 text-sm font-medium transition border-b-2 -mb-px"
:class="activeTab === tab.key
? 'text-indigo-400 border-indigo-400'
: 'text-slate-400 border-transparent hover:text-slate-300'"
>
{{ $t(tab.label) }}
</button>
</nav>
</div>
<!-- ============================================================ -->
<!-- Profile Tab -->
<!-- ============================================================ -->
<div v-if="activeTab === 'profile'" class="space-y-6">
<!-- Personal Info Card -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-5">
<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="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>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.personal_info') }}</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.person_id') }}</label>
<p class="text-sm text-white font-mono">{{ person.id }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.uuid') }}</label>
<p class="text-sm text-white font-mono text-xs">{{ person.id_uuid }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.last_name') }}</label>
<p class="text-sm text-white">{{ person.last_name || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.first_name') }}</label>
<p class="text-sm text-white">{{ person.first_name || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.phone') }}</label>
<p class="text-sm text-white">{{ person.phone || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.birth_place') }}</label>
<p class="text-sm text-white">{{ person.birth_place || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.birth_date') }}</label>
<p class="text-sm text-white">{{ formatDate(person.birth_date) || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.mothers_name') }}</label>
<p class="text-sm text-white">{{ [person.mothers_last_name, person.mothers_first_name].filter(Boolean).join(' ') || '—' }}</p>
</div>
</div>
</div>
<!-- Address Card -->
<div v-if="person.address" class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-5">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.address') }}</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.zip') }}</label>
<p class="text-sm text-white">{{ person.address.address_zip || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.city') }}</label>
<p class="text-sm text-white">{{ person.address.address_city || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.street') }}</label>
<p class="text-sm text-white">{{ [person.address.address_street_type, person.address.address_street_name].filter(Boolean).join(' ') || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.house_number') }}</label>
<p class="text-sm text-white">{{ person.address.address_house_number || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.stairwell') }}</label>
<p class="text-sm text-white">{{ person.address.address_stairwell || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.floor') }}</label>
<p class="text-sm text-white">{{ person.address.address_floor || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.door') }}</label>
<p class="text-sm text-white">{{ person.address.address_door || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.parcel_id') }}</label>
<p class="text-sm text-white">{{ person.address.address_hrsz || '—' }}</p>
</div>
<div v-if="person.address.full_address_text" class="lg:col-span-3">
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.full_address') }}</label>
<p class="text-sm text-white">{{ person.address.full_address_text }}</p>
</div>
</div>
</div>
<!-- System Info Card -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-5">
<div class="p-2 rounded-lg bg-slate-500/10 text-slate-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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.system_info') }}</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.is_sales_agent') }}</label>
<p class="text-sm text-white">{{ person.is_sales_agent ? $t('persons.details.yes') : $t('persons.details.no') }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.lifetime_xp') }}</label>
<p class="text-sm text-white">{{ person.lifetime_xp }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.penalty_points') }}</label>
<p class="text-sm text-white">{{ person.penalty_points }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.social_reputation') }}</label>
<p class="text-sm text-white">{{ person.social_reputation.toFixed(2) }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.created_at') }}</label>
<p class="text-sm text-white">{{ formatDate(person.created_at) || '—' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.updated_at') }}</label>
<p class="text-sm text-white">{{ formatDate(person.updated_at) || '—' }}</p>
</div>
<div v-if="person.deleted_at">
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.deleted_at') }}</label>
<p class="text-sm text-red-400">{{ formatDate(person.deleted_at) }}</p>
</div>
</div>
</div>
<!-- Identity Docs Card -->
<div v-if="hasIdentityDocs" class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-5">
<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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.identity_docs') }}</h3>
</div>
<pre class="text-xs text-slate-300 bg-slate-900/50 rounded-lg p-4 overflow-x-auto">{{ formatJson(person.identity_docs) }}</pre>
</div>
<!-- ICE Contact Card -->
<div v-if="hasIceContact" class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-5">
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.ice_contact') }}</h3>
</div>
<pre class="text-xs text-slate-300 bg-slate-900/50 rounded-lg p-4 overflow-x-auto">{{ formatJson(person.ice_contact) }}</pre>
</div>
</div>
<!-- ============================================================ -->
<!-- Users Tab -->
<!-- ============================================================ -->
<div v-if="activeTab === 'users'" class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-500/10 text-blue-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.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.connected_users') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('persons.details.users_count', { count: person.users.length }) }}</p>
</div>
</div>
</div>
<div v-if="person.users.length === 0" class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-10 h-10 text-slate-600 mx-auto mb-3" 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-sm text-slate-400">{{ $t('persons.details.no_users') }}</p>
</div>
<div v-else 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">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">ID</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.email') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.role') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.status') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.plan') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.language') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.registered_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="u in person.users" :key="u.id" class="border-b border-slate-700/50 last:border-b-0 hover:bg-slate-700/30 transition">
<td class="px-4 py-3">
<NuxtLink
:to="'/users/' + u.id"
class="text-sm font-mono text-indigo-400 hover:underline"
>
{{ u.id }}
</NuxtLink>
</td>
<td class="px-4 py-3">
<NuxtLink
:to="'/users/' + u.id"
class="text-sm font-medium text-white hover:text-indigo-400 hover:underline transition-colors"
>
{{ u.email }}
</NuxtLink>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="roleBadgeClass(u.role)">
{{ u.role || '—' }}
</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="u.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="u.is_active ? 'bg-emerald-400' : 'bg-red-400'"></span>
{{ u.is_active ? $t('persons.active') : $t('persons.inactive') }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="planBadgeClass(u.subscription_plan)">
{{ u.subscription_plan }}
</span>
</td>
<td class="px-4 py-3 text-slate-300 text-xs">{{ u.preferred_language?.toUpperCase() || '—' }}</td>
<td class="px-4 py-3 text-slate-300 text-xs">{{ formatDate(u.created_at) || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- Memberships Tab -->
<!-- ============================================================ -->
<div v-if="activeTab === 'memberships'" class="space-y-6">
<div class="flex items-center justify-between">
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2M10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.memberships') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('persons.details.memberships_count', { count: person.memberships.length }) }}</p>
</div>
</div>
</div>
<div v-if="person.memberships.length === 0" class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-10 h-10 text-slate-600 mx-auto mb-3" 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-sm text-slate-400">{{ $t('persons.details.no_memberships') }}</p>
</div>
<div v-else 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">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.org_name') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.org_type') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.org_role') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.member_status') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.verified') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.joined_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in person.memberships" :key="m.id" class="border-b border-slate-700/50 last:border-b-0 hover:bg-slate-700/30 transition">
<td class="px-4 py-3">
<NuxtLink
:to="'/garages/' + m.organization_id"
class="text-sm font-medium text-white hover:text-indigo-400 hover:underline transition-colors"
>
{{ m.organization_name }}
</NuxtLink>
</td>
<td class="px-4 py-3">
<span class="text-xs text-slate-300">{{ m.organization_type || '—' }}</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="orgRoleBadgeClass(m.role)">
{{ m.role }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium" :class="memberStatusBadgeClass(m.status)">
<span class="w-1.5 h-1.5 rounded-full" :class="memberStatusDotClass(m.status)"></span>
{{ m.status }}
</span>
</td>
<td class="px-4 py-3">
<span v-if="m.is_verified" class="inline-flex items-center gap-1 text-xs text-emerald-400">
<svg class="w-4 h-4" 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>
{{ $t('persons.details.verified_yes') }}
</span>
<span v-else class="text-xs text-slate-500">{{ $t('persons.details.verified_no') }}</span>
</td>
<td class="px-4 py-3 text-slate-300 text-xs">{{ formatDate(m.created_at) || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- Owned Organizations Tab -->
<!-- ============================================================ -->
<div v-if="activeTab === 'owned_orgs'" class="space-y-6">
<div class="flex items-center justify-between">
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2M10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.owned_orgs') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('persons.details.owned_orgs_count', { count: person.owned_organizations.length }) }}</p>
</div>
</div>
</div>
<div v-if="person.owned_organizations.length === 0" class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-10 h-10 text-slate-600 mx-auto mb-3" 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-sm text-slate-400">{{ $t('persons.details.no_owned_orgs') }}</p>
</div>
<div v-else 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">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.org_name') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.org_type') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.status') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('persons.details.created_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="o in person.owned_organizations" :key="o.id" class="border-b border-slate-700/50 last:border-b-0 hover:bg-slate-700/30 transition">
<td class="px-4 py-3">
<NuxtLink
:to="'/garages/' + o.id"
class="text-sm font-medium text-white hover:text-indigo-400 hover:underline transition-colors"
>
{{ o.name }}
</NuxtLink>
</td>
<td class="px-4 py-3">
<span class="text-xs text-slate-300">{{ o.org_type || '—' }}</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="o.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-500/10 text-slate-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="o.is_active ? 'bg-emerald-400' : 'bg-slate-400'"></span>
{{ o.is_active ? $t('persons.active') : $t('persons.inactive') }}
</span>
</td>
<td class="px-4 py-3 text-slate-300 text-xs">{{ formatDate(o.created_at) || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- Merge History Tab -->
<!-- ============================================================ -->
<div v-if="activeTab === 'merge_history'" class="space-y-6">
<div class="flex items-center justify-between">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">{{ $t('persons.details.merge_history') }}</h3>
</div>
</div>
</div>
<div v-if="!person.merge_history" class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-10 h-10 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-slate-400">{{ $t('persons.details.no_merge_history') }}</p>
</div>
<div v-else class="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="p-3 rounded-lg bg-red-500/10 text-red-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<p class="text-sm text-slate-300">
{{ $t('persons.details.merged_into') }}
<NuxtLink
:to="'/persons/' + person.merge_history.merged_into_id"
class="text-indigo-400 hover:underline font-medium"
>
{{ person.merge_history.merged_into_name || '#' + person.merge_history.merged_into_id }}
</NuxtLink>
</p>
<p class="text-xs text-slate-500 mt-1">{{ $t('persons.details.merged_at') }}: {{ formatDate(person.merge_history.merged_at) }}</p>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- Edit Modal -->
<!-- ============================================================ -->
<Teleport to="body">
<div
v-if="showEditModal"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="closeEditModal"></div>
<div class="relative bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl">
<div class="sticky top-0 bg-slate-800 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">{{ $t('persons.details.edit_title') }}</h2>
<button @click="closeEditModal" class="text-slate-400 hover:text-white transition">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6 space-y-5">
<!-- Personal Info Section -->
<div>
<h3 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('persons.details.personal_info') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.last_name') }}</label>
<input
v-model="editForm.last_name"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.details.last_name')"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.first_name') }}</label>
<input
v-model="editForm.first_name"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.details.first_name')"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.phone') }}</label>
<input
v-model="editForm.phone"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.phone')"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.birth_place') }}</label>
<input
v-model="editForm.birth_place"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.details.birth_place')"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.birth_date') }}</label>
<input
v-model="editForm.birth_date"
type="date"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-indigo-500 transition"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.mothers_last_name') }}</label>
<input
v-model="editForm.mothers_last_name"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.details.mothers_last_name')"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('persons.details.mothers_first_name') }}</label>
<input
v-model="editForm.mothers_first_name"
type="text"
class="w-full px-3 py-2 text-sm bg-slate-900/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition"
:placeholder="$t('persons.details.mothers_first_name')"
/>
</div>
</div>
</div>
<!-- Status Section -->
<div class="border-t border-slate-700 pt-5">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('persons.details.status_section') }}</h3>
<div class="flex flex-wrap gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="editForm.is_active"
type="checkbox"
class="w-4 h-4 rounded border-slate-600 bg-slate-900/50 text-indigo-500 focus:ring-indigo-500"
/>
<span class="text-sm text-slate-300">{{ $t('persons.details.is_active') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="editForm.is_ghost"
type="checkbox"
class="w-4 h-4 rounded border-slate-600 bg-slate-900/50 text-purple-500 focus:ring-purple-500"
/>
<span class="text-sm text-slate-300">{{ $t('persons.details.is_ghost') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="editForm.is_sales_agent"
type="checkbox"
class="w-4 h-4 rounded border-slate-600 bg-slate-900/50 text-amber-500 focus:ring-amber-500"
/>
<span class="text-sm text-slate-300">{{ $t('persons.details.is_sales_agent') }}</span>
</label>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="sticky bottom-0 bg-slate-800 border-t border-slate-700 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeEditModal"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
>
{{ $t('persons.details.cancel') }}
</button>
<button
@click="saveEdit"
:disabled="saving"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-500 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="saving" class="w-4 h-4 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>
{{ saving ? $t('persons.details.saving') : $t('persons.details.save') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ============================================================ -->
<!-- Notification Toast -->
<!-- ============================================================ -->
<Teleport to="body">
<div
v-if="notification.show"
class="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-5 py-3 rounded-lg shadow-xl transition-all duration-300"
:class="notification.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
>
<svg v-if="notification.type === 'success'" class="w-5 h-5 flex-shrink-0" 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>
<svg v-else class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">{{ notification.message }}</span>
<button @click="notification.show = false" class="ml-2 text-white/70 hover:text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Teleport>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const personId = route.params.id as string
// State
const loading = ref(true)
const error = ref<string | null>(null)
const person = ref<any>(null)
const activeTab = ref('profile')
// Edit Modal
const showEditModal = ref(false)
const saving = ref(false)
const editForm = ref({
last_name: '',
first_name: '',
phone: '',
mothers_last_name: '',
mothers_first_name: '',
birth_place: '',
birth_date: '',
is_active: true,
is_ghost: false,
is_sales_agent: false,
})
// Notification
const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' })
// Tab definitions
const tabs = [
{ key: 'profile', label: 'persons.details.profile_tab' },
{ key: 'users', label: 'persons.details.users_tab' },
{ key: 'memberships', label: 'persons.details.memberships_tab' },
{ key: 'owned_orgs', label: 'persons.details.owned_orgs_tab' },
{ key: 'merge_history', label: 'persons.details.merge_history_tab' },
]
// Computed
const personDisplayName = computed(() => {
if (!person.value) return ''
const p = person.value
if (p.last_name || p.first_name) {
return [p.last_name, p.first_name].filter(Boolean).join(' ')
}
return 'Person #' + p.id
})
const hasEditPermission = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['persons:edit']
})
const hasIdentityDocs = computed(() => {
if (!person.value?.identity_docs) return false
const docs = person.value.identity_docs
if (typeof docs === 'object' && Object.keys(docs).length > 0) return true
return false
})
const hasIceContact = computed(() => {
if (!person.value?.ice_contact) return false
const ice = person.value.ice_contact
if (typeof ice === 'object' && Object.keys(ice).length > 0) return true
return false
})
// API calls
async function fetchPerson() {
loading.value = true
error.value = null
try {
const tokenCookie = useCookie('access_token')
const token = (tokenCookie.value || auth.token || '').trim()
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
const res = await fetch(`/api/v1/admin/persons/${personId}`, { headers })
if (!res.ok) {
if (res.status === 404) throw new Error('Person not found')
if (res.status === 403) throw new Error('Access denied')
throw new Error(`Failed to fetch person: ${res.status}`)
}
const data = await res.json()
person.value = data
} catch (e: any) {
error.value = e.message || 'Failed to load person details'
} finally {
loading.value = false
}
}
// Edit Modal
function openEditModal() {
if (!person.value) return
const p = person.value
editForm.value = {
last_name: p.last_name || '',
first_name: p.first_name || '',
phone: p.phone || '',
mothers_last_name: p.mothers_last_name || '',
mothers_first_name: p.mothers_first_name || '',
birth_place: p.birth_place || '',
birth_date: p.birth_date ? p.birth_date.slice(0, 10) : '',
is_active: p.is_active,
is_ghost: p.is_ghost,
is_sales_agent: p.is_sales_agent,
}
showEditModal.value = true
}
function closeEditModal() {
showEditModal.value = false
}
async function saveEdit() {
saving.value = true
try {
const tokenCookie = useCookie('access_token')
const token = (tokenCookie.value || auth.token || '').trim()
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
const payload: Record<string, any> = {
last_name: editForm.value.last_name || undefined,
first_name: editForm.value.first_name || undefined,
phone: editForm.value.phone || undefined,
mothers_last_name: editForm.value.mothers_last_name || undefined,
mothers_first_name: editForm.value.mothers_first_name || undefined,
birth_place: editForm.value.birth_place || undefined,
birth_date: editForm.value.birth_date
? new Date(editForm.value.birth_date).toISOString()
: undefined,
is_active: editForm.value.is_active,
is_ghost: editForm.value.is_ghost,
is_sales_agent: editForm.value.is_sales_agent,
}
// Clean undefined values
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) delete payload[key]
})
const res = await fetch(`/api/v1/admin/persons/${personId}`, {
method: 'PATCH',
headers,
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.detail || `Save failed: ${res.status}`)
}
const result = await res.json()
person.value = result
closeEditModal()
showNotification('success', 'Person updated successfully')
} catch (e: any) {
showNotification('error', e.message || 'Failed to save')
} finally {
saving.value = false
}
}
// Notification helper
function showNotification(type: 'success' | 'error', message: string) {
notification.value = { show: true, type, message }
setTimeout(() => {
notification.value.show = false
}, 4000)
}
// Navigation
function goBack() {
router.push('/persons')
}
// Helpers
function getInitials(name: string): string {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
}
function getInitialsColor(name: string): string {
const colors = [
'bg-red-500', 'bg-yellow-500', 'bg-green-500', 'bg-blue-500',
'bg-indigo-500', 'bg-purple-500', 'bg-pink-500', 'bg-teal-500',
]
let hash = 0
for (let i = 0; i < (name || '').length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
function formatDate(date: string | null | undefined): string {
if (!date) return ''
try {
return new Date(date).toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return String(date)
}
}
function formatJson(data: any): string {
if (!data) return ''
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
function roleBadgeClass(role: string): string {
const map: Record<string, string> = {
admin: 'bg-purple-500/10 text-purple-400',
user: 'bg-blue-500/10 text-blue-400',
staff: 'bg-cyan-500/10 text-cyan-400',
superadmin: 'bg-amber-500/10 text-amber-400',
}
return map[role?.toLowerCase()] || 'bg-slate-500/10 text-slate-400'
}
function planBadgeClass(plan: string): string {
const map: Record<string, string> = {
FREE: 'bg-slate-500/10 text-slate-400',
PREMIUM: 'bg-emerald-500/10 text-emerald-400',
ENTERPRISE: 'bg-amber-500/10 text-amber-400',
}
return map[plan?.toUpperCase()] || 'bg-slate-500/10 text-slate-400'
}
function orgRoleBadgeClass(role: string): string {
const map: Record<string, string> = {
admin: 'bg-purple-500/10 text-purple-400',
manager: 'bg-blue-500/10 text-blue-400',
member: 'bg-slate-500/10 text-slate-400',
owner: 'bg-amber-500/10 text-amber-400',
}
return map[role?.toLowerCase()] || 'bg-slate-500/10 text-slate-400'
}
function memberStatusBadgeClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400',
invited: 'bg-blue-500/10 text-blue-400',
suspended: 'bg-red-500/10 text-red-400',
inactive: 'bg-slate-500/10 text-slate-400',
}
return map[status?.toLowerCase()] || 'bg-slate-500/10 text-slate-400'
}
function memberStatusDotClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-emerald-400',
invited: 'bg-blue-400',
suspended: 'bg-red-400',
inactive: 'bg-slate-400',
}
return map[status?.toLowerCase()] || 'bg-slate-400'
}
// Lifecycle
onMounted(() => {
fetchPerson()
})
</script>