admin_users_personels
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2,114 +2,182 @@
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p class="text-slate-400 mt-1">ServiceFinder administration overview</p>
|
||||
<h1 class="text-2xl font-bold text-white">{{ t('dashboard.title') }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ t('dashboard.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stat Tiles Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Active Users -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-indigo-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400 group-hover:bg-indigo-500/20 transition">
|
||||
<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="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>
|
||||
<span class="text-xs font-medium text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded-full">+12%</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">Active Users</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">2,847</p>
|
||||
<p class="text-xs text-slate-500 mt-1">1,240 new this month</p>
|
||||
</div>
|
||||
|
||||
<!-- Total Vehicles -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-cyan-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500/20 transition">
|
||||
<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.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.75c0 .415.336.75.75.75z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded-full">+5%</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">Total Vehicles</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">12,493</p>
|
||||
<p class="text-xs text-slate-500 mt-1">342 added this month</p>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Revenue -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-emerald-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/20 transition">
|
||||
<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="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded-full">+18%</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">Monthly Revenue</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">€48,250</p>
|
||||
<p class="text-xs text-slate-500 mt-1">€6,200 from premium</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Logs -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-rose-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-rose-500/10 text-rose-400 group-hover:bg-rose-500/20 transition">
|
||||
<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="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-rose-400 bg-rose-400/10 px-2 py-1 rounded-full">+3</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">Error Logs</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">23</p>
|
||||
<p class="text-xs text-slate-500 mt-1">3 critical today</p>
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="text-slate-400 flex items-center gap-3">
|
||||
<svg class="w-5 h-5 animate-spin" 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>
|
||||
{{ t('dashboard.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions & Recent Activity -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Quick Actions</h2>
|
||||
<div class="space-y-3">
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-indigo-400 flex-shrink-0" 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>
|
||||
Invite New Admin User
|
||||
</button>
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-cyan-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.75c0 .415.336.75.75.75z" />
|
||||
</svg>
|
||||
Add New Vehicle
|
||||
</button>
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
View Service Reports
|
||||
</button>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
||||
<p class="text-rose-400">{{ t('dashboard.error') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<!-- Stat Tiles Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Active Users -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-indigo-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400 group-hover:bg-indigo-500/20 transition">
|
||||
<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="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>
|
||||
<span v-if="stats.new_users_this_month > 0" class="text-xs font-medium text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded-full">+{{ stats.new_users_this_month }}</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('dashboard.active_users') }}</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">{{ formatNumber(stats.active_users) }}</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ formatNumber(stats.new_users_this_month) }} {{ t('dashboard.new_this_month') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total Vehicles -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-cyan-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500/20 transition">
|
||||
<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.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.75c0 .415.336.75.75.75z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('dashboard.total_vehicles') }}</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">{{ formatNumber(stats.total_vehicles) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Organizations -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-emerald-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/20 transition">
|
||||
<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="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('dashboard.organizations') }}</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">{{ formatNumber(stats.active_organizations_count) }}</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ formatNumber(stats.total_memberships) }} {{ t('dashboard.memberships') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- New Users (This Month) -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-violet-500/50 transition group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 rounded-lg bg-violet-500/10 text-violet-400 group-hover:bg-violet-500/20 transition">
|
||||
<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="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('dashboard.new_users') }}</h3>
|
||||
<p class="text-3xl font-bold text-white mt-2">{{ formatNumber(stats.new_users_this_month) }}</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ formatNumber(stats.new_users_today) }} {{ t('dashboard.new_today') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Recent Activity</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(activity, index) in recentActivities" :key="index" class="flex items-start gap-3">
|
||||
<div class="w-2 h-2 mt-2 rounded-full flex-shrink-0" :class="activity.dotColor" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-slate-300">{{ activity.text }}</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ activity.time }}</p>
|
||||
<!-- Secondary Stats Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Users by Role -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider mb-4">{{ t('dashboard.users_by_role') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(count, role) in stats.users_by_role" :key="role" class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">{{ role }}</span>
|
||||
<span class="text-sm font-semibold text-white">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users by Plan -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider mb-4">{{ t('dashboard.users_by_plan') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(count, plan) in stats.users_by_plan" :key="plan" class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">{{ plan }}</span>
|
||||
<span class="text-sm font-semibold text-white">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users by Language -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider mb-4">{{ t('dashboard.users_by_language') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(count, lang) in stats.users_by_language" :key="lang" class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">{{ lang }}</span>
|
||||
<span class="text-sm font-semibold text-white">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Trend -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
|
||||
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider mb-4">{{ t('dashboard.registration_trend') }}</h3>
|
||||
<div class="flex items-end gap-1 h-32">
|
||||
<div
|
||||
v-for="(day, idx) in registrationTrend"
|
||||
:key="idx"
|
||||
class="flex-1 bg-indigo-500/60 hover:bg-indigo-400/80 rounded-t transition-all cursor-pointer relative group"
|
||||
:style="{ height: day.height + '%' }"
|
||||
:title="day.date + ': ' + day.count + ' users'"
|
||||
>
|
||||
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-700 text-xs text-white px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
|
||||
{{ day.date }}: {{ day.count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions & Recent Activity -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('dashboard.quick_actions') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-indigo-400 flex-shrink-0" 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>
|
||||
{{ t('dashboard.invite_admin') }}
|
||||
</button>
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-cyan-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.75c0 .415.336.75.75.75z" />
|
||||
</svg>
|
||||
{{ t('dashboard.add_vehicle') }}
|
||||
</button>
|
||||
<button class="w-full flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition text-left">
|
||||
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
{{ t('dashboard.view_reports') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('dashboard.recent_activity') }}</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(activity, index) in recentActivities" :key="index" class="flex items-start gap-3">
|
||||
<div class="w-2 h-2 mt-2 rounded-full flex-shrink-0" :class="activity.dotColor" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-slate-300">{{ activity.text }}</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ activity.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,6 +186,63 @@ definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
interface DashboardStats {
|
||||
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
|
||||
total_vehicles: number
|
||||
active_organizations_count: number
|
||||
total_memberships: number
|
||||
registration_trend: { date: string; count: number }[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const stats = ref<DashboardStats>({
|
||||
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,
|
||||
total_vehicles: 0,
|
||||
active_organizations_count: 0,
|
||||
total_memberships: 0,
|
||||
registration_trend: [],
|
||||
})
|
||||
|
||||
const registrationTrend = computed(() => {
|
||||
const trend = stats.value.registration_trend
|
||||
if (!trend.length) return []
|
||||
const maxCount = Math.max(...trend.map(d => d.count))
|
||||
return trend.map(d => ({
|
||||
date: d.date,
|
||||
count: d.count,
|
||||
height: maxCount > 0 ? (d.count / maxCount) * 100 : 0,
|
||||
}))
|
||||
})
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat().format(num)
|
||||
}
|
||||
|
||||
const recentActivities = [
|
||||
{ text: 'New user registered: john.doe@example.com', time: '2 minutes ago', dotColor: 'bg-emerald-400' },
|
||||
{ text: 'Vehicle #V-8842 added to catalog', time: '15 minutes ago', dotColor: 'bg-cyan-400' },
|
||||
@@ -125,4 +250,21 @@ const recentActivities = [
|
||||
{ text: 'System error: DVLA API timeout on request #4421', time: '2 hours ago', dotColor: 'bg-rose-400' },
|
||||
{ text: 'Permission updated for user: superadmin@sf.hu', time: '3 hours ago', dotColor: 'bg-indigo-400' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Read the access_token from cookie for authenticated API calls
|
||||
const tokenCookie = useCookie('access_token')
|
||||
const token = tokenCookie.value
|
||||
const data = await $fetch('/api/v1/admin/users/stats', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
})
|
||||
stats.value = data as DashboardStats
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard stats:', e)
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -54,10 +54,46 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Package Tier Cards Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Package Content (shown when packages exist) -->
|
||||
<div v-else class="space-y-6 mb-8">
|
||||
<!-- Package Type Tabs (Base vs Add-on) -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-2 border-b border-slate-700">
|
||||
<button
|
||||
@click="listTab = 'base'"
|
||||
class="px-5 py-3 text-sm font-medium border-b-2 transition"
|
||||
:class="listTab === 'base'
|
||||
? 'border-indigo-500 text-indigo-300'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-500'"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
Alapcsomagok
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="listTab = 'addon'"
|
||||
class="px-5 py-3 text-sm font-medium border-b-2 transition"
|
||||
:class="listTab === 'addon'
|
||||
? 'border-amber-500 text-amber-300'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-500'"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Kiegészítők
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package Tier Cards Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="pkg in packages"
|
||||
v-for="pkg in listTab === 'base' ? basePackages : addonPackages"
|
||||
:key="pkg.id"
|
||||
class="bg-slate-800 rounded-xl border border-slate-700 p-6 flex flex-col transition-all hover:border-slate-600"
|
||||
>
|
||||
@@ -122,6 +158,10 @@
|
||||
<span class="text-slate-400">{{ $t('packages.max_garages') }}</span>
|
||||
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'max_garages') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-400">{{ $t('packages.max_users') }}</span>
|
||||
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'max_users') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-400">{{ $t('packages.monthly_credits') }}</span>
|
||||
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'monthly_free_credits') }}</span>
|
||||
@@ -163,7 +203,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /grid -->
|
||||
</div> <!-- /package-content-wrapper -->
|
||||
|
||||
<!-- ── Create / Edit Modal (Tabbed Layout) ──────────────────────── -->
|
||||
<div
|
||||
@@ -187,6 +228,71 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 🎯 GLOBAL TYPE SELECTOR (above tabs, below title) -->
|
||||
<div class="px-6 pt-4 pb-2 mb-6">
|
||||
<div class="bg-slate-700/40 border border-slate-600 rounded-xl p-4">
|
||||
<label class="block text-sm font-semibold text-slate-200 mb-3">{{ $t('packages.form_type') }}</label>
|
||||
<div class="flex gap-4">
|
||||
<label
|
||||
class="flex-1 flex items-center gap-3 px-4 py-3 rounded-lg border-2 cursor-pointer transition"
|
||||
:class="form.type === 'base' || form.type === 'private' || form.type === 'corporate'
|
||||
? 'border-indigo-500 bg-indigo-500/10'
|
||||
: 'border-slate-600 bg-slate-700/50 hover:border-slate-500'"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packageType"
|
||||
value="private"
|
||||
v-model="form.type"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
||||
:class="form.type === 'private' || form.type === 'corporate' || form.type === 'base'
|
||||
? 'border-indigo-500'
|
||||
: 'border-slate-500'"
|
||||
>
|
||||
<div
|
||||
v-if="form.type === 'private' || form.type === 'corporate' || form.type === 'base'"
|
||||
class="w-2.5 h-2.5 rounded-full bg-indigo-500"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-white">📦 Alapcsomag (Base Tier)</span>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Teljes jogú csomag járművekkel, garázsokkal, felhasználókkal</p>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="flex-1 flex items-center gap-3 px-4 py-3 rounded-lg border-2 cursor-pointer transition"
|
||||
:class="form.type === 'addon'
|
||||
? 'border-amber-500 bg-amber-500/10'
|
||||
: 'border-slate-600 bg-slate-700/50 hover:border-slate-500'"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packageType"
|
||||
value="addon"
|
||||
v-model="form.type"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
||||
:class="form.type === 'addon' ? 'border-amber-500' : 'border-slate-500'"
|
||||
>
|
||||
<div
|
||||
v-if="form.type === 'addon'"
|
||||
class="w-2.5 h-2.5 rounded-full bg-amber-500"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-white">➕ Kiegészítő (Add-on)</span>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Kiegészítő modul meglévő csomaghoz, korlátozott opciókkal</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex border-b border-slate-700 px-6">
|
||||
<button
|
||||
@@ -206,7 +312,7 @@
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
<!-- ═══ TAB 1: Basic Info ═══ -->
|
||||
<div v-if="activeTab === 'basic'" class="space-y-4">
|
||||
<!-- Name -->
|
||||
<!-- System Name: free-text input for base, dropdown for addon -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_name') }}</label>
|
||||
<input
|
||||
@@ -228,33 +334,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type & Tier Level -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_type') }}</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="private">{{ $t('packages.type_private') }}</option>
|
||||
<option value="corporate">{{ $t('packages.type_corporate') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_tier_level') }}</label>
|
||||
<input
|
||||
v-model.number="form.tier_level"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.form_tier_level_hint') }}</p>
|
||||
</div>
|
||||
<!-- Tier Level (hidden for add-on) -->
|
||||
<div v-if="form.type !== 'addon'">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_tier_level') }}</label>
|
||||
<input
|
||||
v-model.number="form.tier_level"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.form_tier_level_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle & Badge -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Subtitle & Badge (hidden for add-on) -->
|
||||
<div v-if="form.type !== 'addon'" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_subtitle') }}</label>
|
||||
<input
|
||||
@@ -286,8 +380,8 @@
|
||||
<label for="is_custom" class="text-sm text-slate-300">{{ $t('packages.form_is_custom') }}</label>
|
||||
</div>
|
||||
|
||||
<!-- Singleton Rules -->
|
||||
<div class="border-t border-slate-700 pt-4">
|
||||
<!-- Singleton Rules (hidden for add-on) -->
|
||||
<div v-if="form.type !== 'addon'" class="border-t border-slate-700 pt-4">
|
||||
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('packages.singleton_rules') }}</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -381,15 +475,26 @@
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-center">
|
||||
<button
|
||||
@click="removeZone(region.country_code)"
|
||||
class="p-1.5 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
|
||||
:title="'Remove ' + region.country_code"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="applyDefaultPricingToZone(region.country_code)"
|
||||
class="p-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 transition"
|
||||
:title="'Apply DEFAULT pricing to ' + region.country_code"
|
||||
>
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="removeZone(region.country_code)"
|
||||
class="p-1.5 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
|
||||
:title="'Remove ' + region.country_code"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -521,6 +626,15 @@
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.max_users') }}</label>
|
||||
<input
|
||||
v-model.number="form.max_users"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.monthly_credits') }}</label>
|
||||
<input
|
||||
@@ -699,6 +813,7 @@ interface SubscriptionTier {
|
||||
allowances?: {
|
||||
max_vehicles: number
|
||||
max_garages: number
|
||||
max_users: number
|
||||
monthly_free_credits: number
|
||||
} | null
|
||||
marketing?: {
|
||||
@@ -728,6 +843,7 @@ interface PackageForm {
|
||||
currency: string
|
||||
max_vehicles: number
|
||||
max_garages: number
|
||||
max_users: number
|
||||
monthly_free_credits: number
|
||||
max_cost_category_depth: number
|
||||
advanced_reports: boolean
|
||||
@@ -756,6 +872,13 @@ const activeTabLabel = computed(() => {
|
||||
return tab ? tab.label : ''
|
||||
})
|
||||
|
||||
// ── List Tab State (Base vs Add-on) ────────────────────────────────
|
||||
|
||||
const listTab = ref<'base' | 'addon'>('base')
|
||||
|
||||
const basePackages = computed(() => packages.value.filter(p => p.rules?.type !== 'addon'))
|
||||
const addonPackages = computed(() => packages.value.filter(p => p.rules?.type === 'addon'))
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
|
||||
const packages = ref<SubscriptionTier[]>([])
|
||||
@@ -822,6 +945,7 @@ const defaultForm: PackageForm = {
|
||||
currency: 'EUR',
|
||||
max_vehicles: 1,
|
||||
max_garages: 1,
|
||||
max_users: 1,
|
||||
monthly_free_credits: 0,
|
||||
max_cost_category_depth: 3,
|
||||
advanced_reports: false,
|
||||
@@ -902,6 +1026,32 @@ function applyCalculatorToActiveRegion() {
|
||||
showNotification('success', `Applied net price ${formatCurrency(calcNetPrice, calcCurrency)} to ${code} region`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy DEFAULT zone pricing values to the specified region.
|
||||
* Uses Object.assign for Vue reactivity to ensure the UI updates immediately.
|
||||
*/
|
||||
function applyDefaultPricingToZone(countryCode: string) {
|
||||
const defaultZone = form.pricing_zones['DEFAULT']
|
||||
if (!defaultZone) {
|
||||
showNotification('error', 'No DEFAULT pricing zone exists. Set DEFAULT prices first.')
|
||||
return
|
||||
}
|
||||
if (countryCode === 'DEFAULT') return
|
||||
// Ensure the target zone object exists
|
||||
if (!form.pricing_zones[countryCode]) {
|
||||
form.pricing_zones[countryCode] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: 'EUR' }
|
||||
}
|
||||
// Copy DEFAULT values into the target zone using Object.assign for reactivity
|
||||
const target = form.pricing_zones[countryCode]
|
||||
Object.assign(target, {
|
||||
monthly_price: defaultZone.monthly_price,
|
||||
yearly_price: defaultZone.yearly_price,
|
||||
credit_price: defaultZone.credit_price ?? 0,
|
||||
currency: defaultZone.currency,
|
||||
})
|
||||
showNotification('success', `Applied DEFAULT pricing to ${countryCode} region`)
|
||||
}
|
||||
|
||||
// ── API Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function getAuthHeaders() {
|
||||
@@ -914,7 +1064,7 @@ async function fetchPackages() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await $fetch<{ total: number; tiers: SubscriptionTier[] }>('/api/v1/admin/packages', {
|
||||
const res = await $fetch<{ total: number; tiers: SubscriptionTier[] }>('/api/v1/admin/packages?include_hidden=true', {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
packages.value = res.tiers || []
|
||||
@@ -976,6 +1126,10 @@ function openCreateModal() {
|
||||
editingPackage.value = null
|
||||
activeTab.value = 'basic'
|
||||
Object.assign(form, defaultForm)
|
||||
// Context-aware type: if on the Add-on tab, default to addon type
|
||||
if (listTab.value === 'addon') {
|
||||
form.type = 'addon'
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -997,6 +1151,7 @@ function openEditModal(pkg: SubscriptionTier) {
|
||||
form.currency = pricing.currency || 'EUR'
|
||||
form.max_vehicles = allowances.max_vehicles || 1
|
||||
form.max_garages = allowances.max_garages || 1
|
||||
form.max_users = allowances.max_users || 1
|
||||
form.monthly_free_credits = allowances.monthly_free_credits || 0
|
||||
form.subtitle = rules.marketing?.subtitle || ''
|
||||
form.badge = rules.marketing?.badge || ''
|
||||
@@ -1056,6 +1211,7 @@ function duplicatePackage(pkg: SubscriptionTier) {
|
||||
form.currency = pricing.currency || 'EUR'
|
||||
form.max_vehicles = allowances.max_vehicles || 1
|
||||
form.max_garages = allowances.max_garages || 1
|
||||
form.max_users = allowances.max_users || 1
|
||||
form.monthly_free_credits = allowances.monthly_free_credits || 0
|
||||
form.subtitle = rules.marketing?.subtitle || ''
|
||||
form.badge = rules.marketing?.badge || ''
|
||||
@@ -1135,6 +1291,7 @@ async function savePackage() {
|
||||
allowances: {
|
||||
max_vehicles: form.max_vehicles,
|
||||
max_garages: form.max_garages,
|
||||
max_users: form.max_users,
|
||||
monthly_free_credits: form.monthly_free_credits,
|
||||
},
|
||||
marketing: {
|
||||
|
||||
1020
frontend_admin/pages/persons/[id]/index.vue
Normal file
1020
frontend_admin/pages/persons/[id]/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
540
frontend_admin/pages/persons/index.vue
Normal file
540
frontend_admin/pages/persons/index.vue
Normal file
@@ -0,0 +1,540 @@
|
||||
<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>
|
||||
976
frontend_admin/pages/users/[id]/index.vue
Normal file
976
frontend_admin/pages/users/[id]/index.vue
Normal file
@@ -0,0 +1,976 @@
|
||||
<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('users.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="fetchUser" class="px-4 py-2 text-sm bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition">
|
||||
{{ $t('users.details.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else-if="user">
|
||||
<!-- 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('users.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(userDisplayName)"
|
||||
>
|
||||
{{ getInitials(userDisplayName) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-white">{{ userDisplayName }}</h1>
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium"
|
||||
:class="statusBadgeClass"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="statusDotClass"></span>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.is_vip"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400"
|
||||
>
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
VIP
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mt-0.5">
|
||||
{{ user.email }}
|
||||
<span v-if="user.person?.phone" class="ml-2 text-slate-500">· {{ user.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('users.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">
|
||||
<!-- User Account 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('users.details.account_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('users.details.user_id') }}</label>
|
||||
<p class="text-sm text-white font-mono">{{ user.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.email') }}</label>
|
||||
<p class="text-sm text-white">{{ user.email }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.role') }}</label>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.registration_date') }}</label>
|
||||
<p class="text-sm text-white">{{ formatDate(user.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.last_login') }}</label>
|
||||
<p class="text-sm text-white">{{ formatDate(user.last_login_at) || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.ui_mode') }}</label>
|
||||
<p class="text-sm text-white capitalize">{{ user.ui_mode || 'personal' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.language') }}</label>
|
||||
<p class="text-sm text-white uppercase">{{ user.preferred_language || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.region') }}</label>
|
||||
<p class="text-sm text-white">{{ user.region_code || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.currency') }}</label>
|
||||
<p class="text-sm text-white">{{ user.preferred_currency || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription 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-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="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">{{ $t('users.details.subscription_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('users.package') }}</label>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="planBadgeClass(user.subscription_plan)">
|
||||
{{ user.subscription_plan || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.subscription_expires') }}</label>
|
||||
<p class="text-sm text-white">{{ formatDate(user.subscription_expires_at) || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.scope_level') }}</label>
|
||||
<p class="text-sm text-white">{{ user.scope_level || 'individual' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.max_vehicles') }}</label>
|
||||
<p class="text-sm text-white">{{ user.max_vehicles }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.max_garages') }}</label>
|
||||
<p class="text-sm text-white">{{ user.max_garages }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Person Details Card (if exists) -->
|
||||
<div v-if="user.person" 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">{{ $t('users.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('users.details.last_name') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.last_name || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.first_name') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.first_name || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.phone') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.phone || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.birth_place') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.birth_place || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.birth_date') }}</label>
|
||||
<p class="text-sm text-white">{{ formatDate(user.person.birth_date) || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.mothers_name') }}</label>
|
||||
<p class="text-sm text-white">{{ [user.person.mothers_last_name, user.person.mothers_first_name].filter(Boolean).join(' ') || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address sub-card -->
|
||||
<div v-if="user.person.address" class="mt-6 pt-6 border-t border-slate-700">
|
||||
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('users.details.address') }}</h4>
|
||||
<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('users.details.zip') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.address.address_zip || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.city') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.address.address_city || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.street') }}</label>
|
||||
<p class="text-sm text-white">{{ [user.person.address.address_street_type, user.person.address.address_street_name].filter(Boolean).join(' ') || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ $t('users.details.house_number') }}</label>
|
||||
<p class="text-sm text-white">{{ user.person.address.address_house_number || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Person record -->
|
||||
<div v-else class="bg-slate-800/50 border border-slate-700 rounded-xl p-6 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-slate-400">{{ $t('users.details.no_person_record') }}</p>
|
||||
</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('users.details.memberships') }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.details.memberships_count', { count: memberships.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="membershipsLoading" class="flex items-center justify-center py-12">
|
||||
<svg class="w-6 h-6 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>
|
||||
</div>
|
||||
|
||||
<div v-else-if="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('users.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('users.details.org_name') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('users.details.org_role') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('users.details.member_status') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('users.details.joined_at') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('users.details.verified') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in 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_display_name || m.organization_name }}
|
||||
</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(m.role)">
|
||||
{{ orgRoleLabel(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>
|
||||
{{ memberStatusLabel(m.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-300 text-xs">{{ formatDate(m.joined_at) || '—' }}</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('users.details.verified_yes') }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-slate-500">{{ $t('users.details.verified_no') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Edit Modal -->
|
||||
<!-- ============================================================ -->
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="closeEditModal"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="relative w-full max-w-2xl bg-slate-800 border border-slate-700 rounded-2xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-slate-700 flex items-center justify-between flex-shrink-0">
|
||||
<h3 class="text-lg font-semibold text-white">{{ $t('users.details.edit_user') }}</h3>
|
||||
<button @click="closeEditModal" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 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>
|
||||
|
||||
<!-- Scrollable Body -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
<!-- Account Fields -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.email') }}</label>
|
||||
<input
|
||||
v-model="editForm.email"
|
||||
type="email"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.preferred_language') }}</label>
|
||||
<select
|
||||
v-model="editForm.preferred_language"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
>
|
||||
<option value="hu">HU</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="de">DE</option>
|
||||
<option value="ro">RO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.region') }}</label>
|
||||
<input
|
||||
v-model="editForm.region_code"
|
||||
type="text"
|
||||
maxlength="5"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.currency') }}</label>
|
||||
<input
|
||||
v-model="editForm.preferred_currency"
|
||||
type="text"
|
||||
maxlength="3"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.package') }}</label>
|
||||
<select
|
||||
v-model="editForm.subscription_plan"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
>
|
||||
<option value="FREE">FREE</option>
|
||||
<option value="PREMIUM">PREMIUM</option>
|
||||
<option value="ENTERPRISE">ENTERPRISE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.subscription_expires') }}</label>
|
||||
<input
|
||||
v-model="editForm.subscription_expires_at"
|
||||
type="datetime-local"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.scope_level') }}</label>
|
||||
<select
|
||||
v-model="editForm.scope_level"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
>
|
||||
<option value="individual">individual</option>
|
||||
<option value="organization">organization</option>
|
||||
<option value="group">group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.scope_id') }}</label>
|
||||
<input
|
||||
v-model="editForm.scope_id"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 pt-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="editForm.is_active"
|
||||
type="checkbox"
|
||||
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
|
||||
/>
|
||||
<span class="text-sm text-slate-300">{{ $t('users.details.is_active') }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="editForm.is_vip"
|
||||
type="checkbox"
|
||||
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
|
||||
/>
|
||||
<span class="text-sm text-slate-300">{{ $t('users.details.is_vip') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Person Fields -->
|
||||
<div class="border-t border-slate-700 pt-5">
|
||||
<h4 class="text-sm font-semibold text-white mb-4">{{ $t('users.details.personal_info') }}</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.last_name') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_last_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.first_name') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_first_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.phone') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_phone"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.mothers_name') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_mothers_last_name"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.mothers_first_name') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_mothers_first_name"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.birth_place') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_birth_place"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5">{{ $t('users.details.birth_date') }}</label>
|
||||
<input
|
||||
v-model="editForm.person_birth_date"
|
||||
type="date"
|
||||
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t border-slate-700 flex items-center justify-end gap-3 flex-shrink-0">
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-700/50 hover:bg-slate-700 rounded-lg transition"
|
||||
>
|
||||
{{ $t('users.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"
|
||||
>
|
||||
<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('users.details.saving') : $t('users.details.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Notification Toast -->
|
||||
<!-- ============================================================ -->
|
||||
<div
|
||||
v-if="notification.show"
|
||||
class="fixed bottom-6 right-6 z-50 px-5 py-3 rounded-xl shadow-2xl text-sm font-medium transition-all"
|
||||
:class="notification.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg v-if="notification.type === 'success'" 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>
|
||||
<svg v-else 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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ notification.message }}
|
||||
</div>
|
||||
</div>
|
||||
</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 userId = route.params.id as string
|
||||
|
||||
// State
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const user = ref<any>(null)
|
||||
const memberships = ref<any[]>([])
|
||||
const membershipsLoading = ref(false)
|
||||
const activeTab = ref('profile')
|
||||
|
||||
// Edit Modal
|
||||
const showEditModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editForm = ref({
|
||||
email: '',
|
||||
preferred_language: '',
|
||||
region_code: '',
|
||||
preferred_currency: '',
|
||||
subscription_plan: '',
|
||||
subscription_expires_at: '',
|
||||
scope_level: '',
|
||||
scope_id: '',
|
||||
is_active: true,
|
||||
is_vip: false,
|
||||
person_last_name: '',
|
||||
person_first_name: '',
|
||||
person_phone: '',
|
||||
person_mothers_last_name: '',
|
||||
person_mothers_first_name: '',
|
||||
person_birth_place: '',
|
||||
person_birth_date: '',
|
||||
})
|
||||
|
||||
// Notification
|
||||
const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' })
|
||||
|
||||
// Tab definitions
|
||||
const tabs = [
|
||||
{ key: 'profile', label: 'users.details.profile_tab' },
|
||||
{ key: 'memberships', label: 'users.details.memberships_tab' },
|
||||
]
|
||||
|
||||
// Computed
|
||||
const userDisplayName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const p = user.value.person
|
||||
if (p?.last_name || p?.first_name) {
|
||||
return [p.last_name, p.first_name].filter(Boolean).join(' ')
|
||||
}
|
||||
return user.value.email || 'User #' + user.value.id
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const map: Record<string, string> = {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
deleted: 'Deleted',
|
||||
banned: 'Banned',
|
||||
}
|
||||
return map[user.value.status?.toLowerCase()] || user.value.status || 'Active'
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const map: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400',
|
||||
inactive: 'bg-slate-500/10 text-slate-400',
|
||||
deleted: 'bg-red-500/10 text-red-400',
|
||||
banned: 'bg-red-500/10 text-red-400',
|
||||
}
|
||||
return map[user.value.status?.toLowerCase()] || 'bg-emerald-500/10 text-emerald-400'
|
||||
})
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const map: Record<string, string> = {
|
||||
active: 'bg-emerald-400',
|
||||
inactive: 'bg-slate-400',
|
||||
deleted: 'bg-red-400',
|
||||
banned: 'bg-red-400',
|
||||
}
|
||||
return map[user.value.status?.toLowerCase()] || 'bg-emerald-400'
|
||||
})
|
||||
|
||||
const hasEditPermission = computed(() => {
|
||||
if (auth.isAdmin) return true
|
||||
return !!auth.user?.system_capabilities?.['users:edit']
|
||||
})
|
||||
|
||||
// API calls
|
||||
async function fetchUser() {
|
||||
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/users/${userId}`, { headers })
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error('User not found')
|
||||
if (res.status === 403) throw new Error('Access denied')
|
||||
throw new Error(`Failed to fetch user: ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
user.value = data
|
||||
|
||||
// Fetch memberships after user loads
|
||||
fetchMemberships()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load user details'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMemberships() {
|
||||
membershipsLoading.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 res = await fetch(`/api/v1/admin/users/${userId}/memberships`, { headers })
|
||||
if (!res.ok) throw new Error(`Failed to fetch memberships: ${res.status}`)
|
||||
|
||||
const data = await res.json()
|
||||
memberships.value = Array.isArray(data) ? data : (data.memberships || [])
|
||||
} catch (e: any) {
|
||||
console.error('Memberships fetch error:', e)
|
||||
memberships.value = []
|
||||
} finally {
|
||||
membershipsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Modal
|
||||
function openEditModal() {
|
||||
if (!user.value) return
|
||||
const u = user.value
|
||||
const p = u.person || {}
|
||||
editForm.value = {
|
||||
email: u.email || '',
|
||||
preferred_language: u.preferred_language || '',
|
||||
region_code: u.region_code || '',
|
||||
preferred_currency: u.preferred_currency || '',
|
||||
subscription_plan: u.subscription_plan || '',
|
||||
subscription_expires_at: u.subscription_expires_at
|
||||
? u.subscription_expires_at.slice(0, 16)
|
||||
: '',
|
||||
scope_level: u.scope_level || '',
|
||||
scope_id: u.scope_id || '',
|
||||
is_active: u.status === 'active',
|
||||
is_vip: u.is_vip || false,
|
||||
person_last_name: p.last_name || '',
|
||||
person_first_name: p.first_name || '',
|
||||
person_phone: p.phone || '',
|
||||
person_mothers_last_name: p.mothers_last_name || '',
|
||||
person_mothers_first_name: p.mothers_first_name || '',
|
||||
person_birth_place: p.birth_place || '',
|
||||
person_birth_date: p.birth_date ? p.birth_date.slice(0, 10) : '',
|
||||
}
|
||||
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> = {
|
||||
email: editForm.value.email || undefined,
|
||||
preferred_language: editForm.value.preferred_language || undefined,
|
||||
region_code: editForm.value.region_code || undefined,
|
||||
preferred_currency: editForm.value.preferred_currency || undefined,
|
||||
subscription_plan: editForm.value.subscription_plan || undefined,
|
||||
subscription_expires_at: editForm.value.subscription_expires_at
|
||||
? new Date(editForm.value.subscription_expires_at).toISOString()
|
||||
: null,
|
||||
scope_level: editForm.value.scope_level || undefined,
|
||||
scope_id: editForm.value.scope_id ? Number(editForm.value.scope_id) : undefined,
|
||||
is_active: editForm.value.is_active,
|
||||
is_vip: editForm.value.is_vip,
|
||||
}
|
||||
|
||||
// Only include person fields if any are filled
|
||||
const hasPersonData = editForm.value.person_last_name || editForm.value.person_first_name ||
|
||||
editForm.value.person_phone || editForm.value.person_mothers_last_name ||
|
||||
editForm.value.person_mothers_first_name || editForm.value.person_birth_place ||
|
||||
editForm.value.person_birth_date
|
||||
|
||||
if (hasPersonData) {
|
||||
payload.person = {
|
||||
last_name: editForm.value.person_last_name || undefined,
|
||||
first_name: editForm.value.person_first_name || undefined,
|
||||
phone: editForm.value.person_phone || undefined,
|
||||
mothers_last_name: editForm.value.person_mothers_last_name || undefined,
|
||||
mothers_first_name: editForm.value.person_mothers_first_name || undefined,
|
||||
birth_place: editForm.value.person_birth_place || undefined,
|
||||
birth_date: editForm.value.person_birth_date
|
||||
? new Date(editForm.value.person_birth_date).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Clean undefined values
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === undefined) delete payload[key]
|
||||
})
|
||||
if (payload.person) {
|
||||
Object.keys(payload.person).forEach(key => {
|
||||
if (payload.person[key] === undefined) delete payload.person[key]
|
||||
})
|
||||
if (Object.keys(payload.person).length === 0) delete payload.person
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/admin/users/${userId}`, {
|
||||
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()
|
||||
user.value = result
|
||||
closeEditModal()
|
||||
showNotification('success', 'User 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('/users')
|
||||
}
|
||||
|
||||
// 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 roleLabel(role: string): string {
|
||||
const map: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
staff: 'Staff',
|
||||
superadmin: 'Superadmin',
|
||||
}
|
||||
return map[role?.toLowerCase()] || role || 'User'
|
||||
}
|
||||
|
||||
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 orgRoleLabel(role: string): string {
|
||||
const map: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
manager: 'Manager',
|
||||
member: 'Member',
|
||||
owner: 'Owner',
|
||||
}
|
||||
return map[role?.toLowerCase()] || role || 'Member'
|
||||
}
|
||||
|
||||
function memberStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
active: 'Active',
|
||||
invited: 'Invited',
|
||||
suspended: 'Suspended',
|
||||
inactive: 'Inactive',
|
||||
}
|
||||
return map[status?.toLowerCase()] || status || 'Unknown'
|
||||
}
|
||||
|
||||
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(() => {
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
776
frontend_admin/pages/users/index.vue
Normal file
776
frontend_admin/pages/users/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user