Files
service-finder/frontend_admin/pages/providers/[id]/index.vue
2026-07-01 19:49:58 +00:00

1160 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<!-- Back Button & 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>
Vissza a szolgáltatókhoz
</button>
<h1 class="text-2xl font-bold text-white">Szolgáltató részletek</h1>
<p class="text-slate-400 mt-1">Szolgáltató #{{ $route.params.id }}</p>
</div>
<!-- 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>
<span>Szolgáltató betöltése...</span>
</div>
</div>
<!-- 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">Hiba történt a szolgáltató betöltése közben.</p>
<button @click="fetchProvider" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">Újrapróbálkozás</button>
</div>
<!-- Not Found -->
<div v-else-if="!provider" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<p class="text-slate-400 text-center py-12">A szolgáltató nem található.</p>
</div>
<template v-else>
<!-- Status Banner -->
<div
class="rounded-xl border p-4 mb-6 flex items-center gap-3"
:class="statusBannerClass"
>
<span class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center" :class="statusIconClass">
<svg v-if="provider.status === 'approved'" 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="M5 13l4 4L19 7" />
</svg>
<svg v-else-if="provider.status === 'pending'" 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>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</span>
<div>
<p class="text-sm font-medium" :class="statusTextClass">{{ statusLabel(provider.status) }}</p>
<p class="text-xs mt-0.5" :class="statusSubtextClass">{{ statusDescription(provider.status) }}</p>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-slate-700 mb-6">
<nav class="flex gap-6 -mb-px">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="pb-3 text-sm font-medium border-b-2 transition flex items-center gap-2"
:class="activeTab === tab.key ? 'text-indigo-400 border-indigo-400' : 'text-slate-400 border-transparent hover:text-slate-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</nav>
</div>
<!-- Tab: Data -->
<div v-if="activeTab === 'data'" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Basic Info -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Information -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Alapadatok</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Név</dt>
<dd class="text-sm text-white mt-1 font-medium">{{ provider.name }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Kategória</dt>
<dd class="text-sm text-white mt-1">{{ provider.category || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Forrás</dt>
<dd class="mt-1">
<code class="text-sm font-mono text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded">{{ provider.source }}</code>
</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Validációs pontszám</dt>
<dd class="mt-1">
<div class="flex items-center gap-2">
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="provider.validation_score >= 50 ? 'bg-emerald-500' : provider.validation_score >= 20 ? 'bg-amber-500' : 'bg-slate-500'"
:style="{ width: Math.min(provider.validation_score, 100) + '%' }"
/>
</div>
<span class="text-sm text-white font-medium">{{ provider.validation_score }}</span>
</div>
</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Létrehozva</dt>
<dd class="text-sm text-white mt-1">{{ formatDate(provider.created_at) }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Beküldő user ID</dt>
<dd class="text-sm text-white mt-1">{{ provider.added_by_user_id || '—' }}</dd>
</div>
</dl>
</div>
<!-- Address Information -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Cím adatok</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<dt class="text-xs text-slate-500 uppercase tracking-wider">Teljes cím</dt>
<dd class="text-sm text-white mt-1">{{ provider.address || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Város</dt>
<dd class="text-sm text-white mt-1">{{ provider.city || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Irányítószám</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_zip || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Utca</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_street_name || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Házszám</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_house_number || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Plus Code</dt>
<dd class="text-sm text-white mt-1">{{ provider.plus_code || '—' }}</dd>
</div>
</dl>
</div>
<!-- Contact Information -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Elérhetőség</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Telefon</dt>
<dd class="text-sm text-white mt-1">{{ provider.contact_phone || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Email</dt>
<dd class="text-sm text-white mt-1">{{ provider.contact_email || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">Weboldal</dt>
<dd class="text-sm text-white mt-1">
<a v-if="provider.website" :href="provider.website" target="_blank" class="text-indigo-400 hover:text-indigo-300 underline">
{{ provider.website }}
</a>
<span v-else></span>
</dd>
</div>
</dl>
</div>
<!-- Services (Categories) -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Szolgáltatások</h2>
<div v-if="provider.category_ids && provider.category_ids.length > 0" class="flex flex-wrap gap-2">
<span
v-for="catId in provider.category_ids"
:key="catId"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-500/10 text-indigo-300 rounded-full text-xs font-medium"
>
#{{ catId }}
</span>
</div>
<p v-else class="text-sm text-slate-500 italic">Nincsenek kategóriák beállítva</p>
</div>
<!-- Vehicle Classes -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Járműosztályok</h2>
<div v-if="provider.supported_vehicle_classes && provider.supported_vehicle_classes.length > 0" class="flex flex-wrap gap-2">
<span
v-for="vc in provider.supported_vehicle_classes"
:key="vc"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-emerald-500/10 text-emerald-300 rounded-full text-xs font-medium"
>
{{ vehicleClassLabel(vc) }}
</span>
</div>
<p v-else class="text-sm text-slate-500 italic">Nincsenek járműosztályok beállítva</p>
</div>
<!-- Specializations -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Specializációk</h2>
<div v-if="hasSpecializations" class="space-y-3">
<!-- Brands -->
<div v-if="provider.specializations?.brands?.length">
<h3 class="text-xs text-slate-500 uppercase tracking-wider mb-2">Márkák</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="brand in provider.specializations.brands"
:key="brand"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-amber-500/10 text-amber-300 rounded-full text-xs font-medium"
>
{{ brand }}
</span>
</div>
</div>
<!-- Propulsion -->
<div v-if="provider.specializations?.propulsion?.length">
<h3 class="text-xs text-slate-500 uppercase tracking-wider mb-2">Hajtáslánc</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="prop in provider.specializations.propulsion"
:key="prop"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-cyan-500/10 text-cyan-300 rounded-full text-xs font-medium"
>
{{ prop }}
</span>
</div>
</div>
</div>
<p v-else class="text-sm text-slate-500 italic">Nincsenek specializációk beállítva</p>
</div>
<!-- Opening Hours -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Nyitvatartás</h2>
<div v-if="hasOpeningHours" class="overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left text-xs text-slate-500 uppercase tracking-wider pb-2 pr-4">Nap</th>
<th class="text-left text-xs text-slate-500 uppercase tracking-wider pb-2">Nyitva</th>
</tr>
</thead>
<tbody>
<tr v-for="day in DAYS" :key="day.key" class="border-b border-slate-700/50 last:border-0">
<td class="py-2 pr-4 text-white font-medium">{{ day.label }}</td>
<td class="py-2">
<template v-if="getDayHours(day.key)">
<span class="text-white">{{ getDayHours(day.key)?.open }}</span>
<span class="text-slate-500 mx-1"></span>
<span class="text-white">{{ getDayHours(day.key)?.close }}</span>
</template>
<span v-else class="text-slate-500 italic">Zárva</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<p class="text-sm text-slate-500 italic">Nincs beállítva nyitvatartás</p>
</div>
</div>
</div>
<!-- Right Column: Actions -->
<div class="space-y-4">
<!-- Moderation Actions -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Moderációs műveletek</h2>
<!-- Pending Actions -->
<div v-if="provider.status === 'pending'" class="space-y-3">
<button
@click="approveProvider"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white rounded-lg text-sm font-medium transition flex items-center justify-center gap-2"
>
<svg v-if="actionLoading" class="w-4 h-4 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>
<svg v-else 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="M5 13l4 4L19 7" />
</svg>
Jóváhagyás
</button>
<button
@click="openRejectModal"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-rose-600/20 hover:bg-rose-600/40 disabled:opacity-50 text-rose-400 rounded-lg text-sm font-medium transition flex items-center justify-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="M6 18L18 6M6 6l12 12" />
</svg>
Elutasítás
</button>
</div>
<!-- Approved Actions -->
<div v-else-if="provider.status === 'approved'" class="space-y-3">
<NuxtLink
:to="`/providers/${provider.id}/edit`"
class="w-full px-4 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition flex items-center justify-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="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>
Szerkesztés
</NuxtLink>
<button
@click="openFlagModal"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-purple-600/20 hover:bg-purple-600/40 disabled:opacity-50 text-purple-400 rounded-lg text-sm font-medium transition flex items-center justify-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="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Megjelölés (Flag)
</button>
<button
@click="deleteProvider"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-rose-600/20 hover:bg-rose-600/40 disabled:opacity-50 text-rose-400 rounded-lg text-sm font-medium transition flex items-center justify-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="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>
Törlés (Soft Delete)
</button>
</div>
<!-- Rejected / Flagged Actions -->
<div v-else class="space-y-3">
<p class="text-sm text-slate-400 text-center py-2">
A szolgáltató {{ provider.status === 'rejected' ? 'elutasításra' : 'megjelölésre' }} került.
</p>
<NuxtLink
:to="`/providers/${provider.id}/edit`"
class="w-full px-4 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition flex items-center justify-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="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>
Szerkesztés
</NuxtLink>
<button
v-if="provider.status === 'rejected'"
@click="restoreProvider"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white rounded-lg text-sm font-medium transition flex items-center justify-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="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>
Visszaállítás
</button>
<button
@click="deleteProvider"
:disabled="actionLoading"
class="w-full px-4 py-3 bg-rose-600/20 hover:bg-rose-600/40 disabled:opacity-50 text-rose-400 rounded-lg text-sm font-medium transition flex items-center justify-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="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>
Törlés
</button>
</div>
</div>
<!-- Evidence Image -->
<div v-if="provider.evidence_image_path" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Bizonyíték kép</h2>
<img
:src="provider.evidence_image_path"
alt="Evidence"
class="w-full rounded-lg border border-slate-600"
/>
</div>
</div>
</div>
<!-- Tab: Status -->
<div v-if="activeTab === 'status'" class="max-w-2xl">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Státusz kezelés</h2>
<p class="text-sm text-slate-400 mb-6">Módosítsd a szolgáltató státuszát és validációs pontszámát.</p>
<form @submit.prevent="saveStatusEdit">
<div class="space-y-4 mb-6">
<!-- Status Select -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Státusz</label>
<select
v-model="statusForm.status"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="pending">Függőben</option>
<option value="approved">Jóváhagyott</option>
<option value="rejected">Elutasított</option>
</select>
</div>
<!-- Validation Score -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">
Validációs pontszám (0-100)
</label>
<div class="flex items-center gap-3">
<input
v-model.number="statusForm.validation_score"
type="range"
min="0"
max="100"
class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
<span class="text-sm text-white font-medium w-8 text-right">{{ statusForm.validation_score }}</span>
</div>
</div>
<!-- Reason -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Indoklás (opcionális)</label>
<textarea
v-model="statusForm.reason"
rows="3"
placeholder="Add meg a státuszváltoztatás okát..."
class="w-full px-3 py-2 bg-slate-700 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 text-sm resize-none"
></textarea>
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="resetStatusForm" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">Mégsem</button>
<button type="submit" :disabled="statusLoading" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="statusLoading" class="w-4 h-4 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>
Státusz mentése
</button>
</div>
</form>
</div>
</div>
<!-- Tab: Validations -->
<div v-if="activeTab === 'validations'">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Gamification Validációk</h2>
<p class="text-sm text-slate-400 mb-6">A szolgáltatóhoz tartozó gamification validációs bejegyzések listája.</p>
<!-- Loading -->
<div v-if="validationsLoading" class="flex items-center justify-center py-8">
<svg class="w-5 h-5 animate-spin text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<!-- Empty -->
<div v-else-if="validations.length === 0" class="text-center py-8">
<p class="text-slate-500">Nincsenek validációs bejegyzések ehhez a szolgáltatóhoz.</p>
</div>
<!-- Validation List -->
<div v-else class="space-y-3">
<div
v-for="v in validations"
:key="v.id"
class="bg-slate-700/50 rounded-lg border border-slate-600 p-4"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="validationTypeClass(v.validation_type)"
>
{{ validationTypeLabel(v.validation_type) }}
</span>
<span class="text-xs text-slate-500">súly: {{ v.weight }}</span>
</div>
<p class="text-sm text-slate-300">
<span class="text-slate-400">Szavazó:</span>
{{ v.voter_name || `User #${v.voter_user_id}` }}
</p>
<p v-if="v.validation_metadata?.reason" class="text-xs text-slate-500 mt-1">
Indoklás: {{ v.validation_metadata.reason }}
</p>
</div>
<div class="text-xs text-slate-500 whitespace-nowrap">
{{ formatDate(v.created_at) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: History -->
<div v-if="activeTab === 'history'">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Állapotváltoztatások előzményei</h2>
<p class="text-sm text-slate-400 mb-6">Idővonal: ki, mikor és mit változtatott a szolgáltatóval kapcsolatban.</p>
<!-- Loading -->
<div v-if="historyLoading" class="flex items-center justify-center py-8">
<svg class="w-5 h-5 animate-spin text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<!-- Empty -->
<div v-else-if="history.length === 0" class="text-center py-8">
<p class="text-slate-500">Nincsenek előzmények ehhez a szolgáltatóhoz.</p>
</div>
<!-- Timeline -->
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-700"></div>
<div v-for="(entry, index) in history" :key="entry.id" class="relative pl-10 pb-6 last:pb-0">
<!-- Timeline dot -->
<div
class="absolute left-2.5 w-3 h-3 rounded-full border-2 mt-1.5"
:class="historyDotClass(entry.action)"
></div>
<div class="bg-slate-700/50 rounded-lg border border-slate-600 p-4">
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="historyActionClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
<span class="text-xs text-slate-500 ml-2">
User #{{ entry.user_id || '?' }}
</span>
</div>
<div class="text-xs text-slate-500 whitespace-nowrap">
{{ formatDate(entry.timestamp) }}
</div>
</div>
<!-- Status change details -->
<div v-if="entry.new_data" class="text-xs text-slate-400 space-y-1 mt-2">
<div v-if="entry.new_data.old_status || entry.new_data.new_status" class="flex items-center gap-2">
<span class="text-slate-500">Státusz:</span>
<span class="text-rose-400">{{ entry.new_data.old_status || '?' }}</span>
<svg class="w-3 h-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="text-emerald-400">{{ entry.new_data.new_status || '?' }}</span>
</div>
<div v-if="entry.new_data.reason && entry.new_data.reason !== 'Admin edit'">
<span class="text-slate-500">Indoklás:</span> {{ entry.new_data.reason }}
</div>
<!-- Field changes from PATCH update -->
<div v-if="entry.new_data.changes" class="mt-2">
<details class="text-xs">
<summary class="text-slate-500 cursor-pointer hover:text-slate-400">Részletes változások</summary>
<div class="mt-2 space-y-1">
<div v-for="(value, key) in entry.new_data.changes" :key="key" class="flex gap-2">
<span class="text-slate-500 w-28 flex-shrink-0">{{ key }}:</span>
<span class="text-slate-300 truncate">{{ value ?? '—' }}</span>
</div>
</div>
</details>
</div>
</div>
<!-- Old data diff -->
<div v-if="entry.old_data" class="mt-2">
<details class="text-xs">
<summary class="text-slate-500 cursor-pointer hover:text-slate-400">Régi adatok</summary>
<div class="mt-2 space-y-1">
<div v-for="(value, key) in entry.old_data" :key="key" class="flex gap-2">
<span class="text-slate-500 w-28 flex-shrink-0">{{ key }}:</span>
<span class="text-slate-300 truncate">{{ value ?? '—' }}</span>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Reject Modal -->
<div v-if="showRejectModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showRejectModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">Szolgáltató elutasítása</h2>
<p class="text-sm text-slate-400 mb-6">
Biztosan elutasítod a(z) <strong class="text-white">{{ provider?.name }}</strong> szolgáltatót?
</p>
<form @submit.prevent="rejectProvider">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-1">Indoklás (opcionális)</label>
<textarea
v-model="rejectReason"
rows="3"
placeholder="Add meg az elutasítás okát..."
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-rose-500/50 focus:border-rose-500 text-sm resize-none"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="showRejectModal = false" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">Mégse</button>
<button type="submit" :disabled="actionLoading" class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="actionLoading" class="w-4 h-4 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>
Elutasítás
</button>
</div>
</form>
</div>
</div>
<!-- Flag Modal -->
<div v-if="showFlagModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFlagModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">Szolgáltató megjelölése</h2>
<p class="text-sm text-slate-400 mb-6">
Biztosan megjelölöd a(z) <strong class="text-white">{{ provider?.name }}</strong> szolgáltatót?
</p>
<form @submit.prevent="flagProvider">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-1">Indoklás (opcionális)</label>
<textarea
v-model="flagReason"
rows="3"
placeholder="Add meg a megjelölés okát..."
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 text-sm resize-none"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="showFlagModal = false" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">Mégse</button>
<button type="submit" :disabled="actionLoading" class="px-4 py-2 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="actionLoading" class="w-4 h-4 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>
Megjelölés
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// ── State ──────────────────────────────────────────────
const loading = ref(true)
const error = ref(false)
const provider = ref<any>(null)
const actionLoading = ref(false)
// Toast
const toast = ref<{ message: string; type: 'success' | 'error' } | null>(null)
// Reject modal
const showRejectModal = ref(false)
const rejectReason = ref('')
// Flag modal
const showFlagModal = ref(false)
const flagReason = ref('')
// Tabs
const activeTab = ref<'data' | 'status' | 'validations' | 'history'>('data')
// Validations
const validations = ref<any[]>([])
const validationsLoading = ref(false)
// History
const history = ref<any[]>([])
const historyLoading = ref(false)
// Status form
const statusForm = ref({
status: 'pending',
validation_score: 0,
reason: '',
})
const statusLoading = ref(false)
// ── Tab definitions ────────────────────────────────────
const tabs = [
{ key: 'data', label: 'Adatok', icon: 'svg' },
{ key: 'status', label: 'Státusz', icon: 'svg' },
{ key: 'validations', label: 'Validációk', icon: 'svg' },
{ key: 'history', label: 'Előzmények', icon: 'svg' },
]
// ── Computed ───────────────────────────────────────────
const statusBannerClass = computed(() => {
switch (provider.value?.status) {
case 'approved': return 'bg-emerald-500/10 border-emerald-500/30'
case 'pending': return 'bg-amber-500/10 border-amber-500/30'
default: return 'bg-rose-500/10 border-rose-500/30'
}
})
const statusIconClass = computed(() => {
switch (provider.value?.status) {
case 'approved': return 'bg-emerald-500/20 text-emerald-400'
case 'pending': return 'bg-amber-500/20 text-amber-400'
default: return 'bg-rose-500/20 text-rose-400'
}
})
const statusTextClass = computed(() => {
switch (provider.value?.status) {
case 'approved': return 'text-emerald-400'
case 'pending': return 'text-amber-400'
default: return 'text-rose-400'
}
})
const statusSubtextClass = computed(() => {
switch (provider.value?.status) {
case 'approved': return 'text-emerald-400/60'
case 'pending': return 'text-amber-400/60'
default: return 'text-rose-400/60'
}
})
/**
* Returns true if the provider has at least one day with opening hours set.
*/
const hasOpeningHours = computed(() => {
const hours = provider.value?.opening_hours
if (!hours || typeof hours !== 'object') return false
return DAYS.some(day => {
const slot = hours[day.key]
return slot && typeof slot === 'object' && slot.open && slot.close
})
})
/**
* Returns true if the provider has any specializations (brands or propulsion).
*/
const hasSpecializations = computed(() => {
const spec = provider.value?.specializations
if (!spec || typeof spec !== 'object') return false
return (Array.isArray(spec.brands) && spec.brands.length > 0) ||
(Array.isArray(spec.propulsion) && spec.propulsion.length > 0)
})
// ── DAYS constant ──────────────────────────────────────
const DAYS = [
{ key: 'monday', label: 'Hétfő' },
{ key: 'tuesday', label: 'Kedd' },
{ key: 'wednesday', label: 'Szerda' },
{ key: 'thursday', label: 'Csütörtök' },
{ key: 'friday', label: 'Péntek' },
{ key: 'saturday', label: 'Szombat' },
{ key: 'sunday', label: 'Vasárnap' },
]
/**
* Returns the opening hours slot for a given day key, or null if closed.
*/
function getDayHours(dayKey: string): { open: string; close: string } | null {
const hours = provider.value?.opening_hours
if (!hours || typeof hours !== 'object') return null
const slot = hours[dayKey]
if (slot && typeof slot === 'object' && slot.open && slot.close) {
return { open: slot.open, close: slot.close }
}
return null
}
// ── Helper functions ───────────────────────────────────
function statusLabel(status: string): string {
switch (status) {
case 'approved': return 'Jóváhagyva'
case 'pending': return 'Függőben'
case 'rejected': return 'Elutasítva'
default: return 'Ismeretlen'
}
}
function statusDescription(status: string): string {
switch (status) {
case 'approved': return 'A szolgáltató aktív és látható a felhasználók számára.'
case 'pending': return 'A szolgáltató még nem lett jóváhagyva, moderálásra vár.'
case 'rejected': return 'A szolgáltató elutasításra került, nem látható a felhasználók számára.'
default: return ''
}
}
/**
* Maps vehicle class keys to Hungarian labels.
*/
function vehicleClassLabel(key: string): string {
const labels: Record<string, string> = {
car: 'Személyautó',
motorcycle: 'Motorkerékpár',
truck: 'Teherautó',
hgv: 'Nehézgépjármű (HGV)',
light_commercial: 'Kishaszonjármű',
personal: 'Személyes',
boat: 'Csónak/Vízi',
other: 'Egyéb',
}
return labels[key] || key
}
function validationTypeLabel(type: string): string {
const labels: Record<string, string> = {
phone_verified: 'Telefon igazolt',
email_verified: 'Email igazolt',
address_verified: 'Cím igazolt',
website_verified: 'Weboldal igazolt',
manual_review: 'Kézi ellenőrzés',
community_vouch: 'Közösségi ajánlás',
business_hours: 'Nyitvatartás ellenőrzött',
photo_evidence: 'Fénykép bizonyíték',
}
return labels[type] || type
}
function validationTypeClass(type: string): string {
const classes: Record<string, string> = {
phone_verified: 'bg-emerald-500/20 text-emerald-400',
email_verified: 'bg-blue-500/20 text-blue-400',
address_verified: 'bg-amber-500/20 text-amber-400',
website_verified: 'bg-purple-500/20 text-purple-400',
manual_review: 'bg-indigo-500/20 text-indigo-400',
community_vouch: 'bg-pink-500/20 text-pink-400',
business_hours: 'bg-cyan-500/20 text-cyan-400',
photo_evidence: 'bg-orange-500/20 text-orange-400',
}
return classes[type] || 'bg-slate-500/20 text-slate-400'
}
function historyActionLabel(action: string): string {
const labels: Record<string, string> = {
approve: 'Jóváhagyás',
reject: 'Elutasítás',
restore: 'Visszaállítás',
flag: 'Megjelölés',
update: 'Módosítás',
delete: 'Törlés',
create: 'Létrehozás',
}
return labels[action] || action
}
function historyActionClass(action: string): string {
const classes: Record<string, string> = {
approve: 'bg-emerald-500/20 text-emerald-400',
reject: 'bg-rose-500/20 text-rose-400',
restore: 'bg-blue-500/20 text-blue-400',
flag: 'bg-purple-500/20 text-purple-400',
update: 'bg-amber-500/20 text-amber-400',
delete: 'bg-red-500/20 text-red-400',
create: 'bg-green-500/20 text-green-400',
}
return classes[action] || 'bg-slate-500/20 text-slate-400'
}
function historyDotClass(action: string): string {
const classes: Record<string, string> = {
approve: 'border-emerald-500 bg-emerald-500/30',
reject: 'border-rose-500 bg-rose-500/30',
restore: 'border-blue-500 bg-blue-500/30',
flag: 'border-purple-500 bg-purple-500/30',
update: 'border-amber-500 bg-amber-500/30',
delete: 'border-red-500 bg-red-500/30',
create: 'border-green-500 bg-green-500/30',
}
return classes[action] || 'border-slate-500 bg-slate-500/30'
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch {
return dateStr
}
}
// ── Auth helpers ───────────────────────────────────────
function getHeaders(): Record<string, string> {
const token = useCookie('access_token').value
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
return headers
}
// ── API calls ──────────────────────────────────────────
async function fetchProvider() {
loading.value = true
error.value = false
try {
const res = await $fetch(`/api/v1/admin/providers/${route.params.id}`, {
headers: getHeaders(),
})
provider.value = res as any
// Initialize status form from provider data
statusForm.value.status = (res as any).status || 'pending'
statusForm.value.validation_score = (res as any).validation_score || 0
} catch (err: any) {
console.error('Failed to fetch provider:', err)
error.value = true
} finally {
loading.value = false
}
}
async function fetchValidations() {
validationsLoading.value = true
try {
const res = await $fetch(`/api/v1/admin/providers/${route.params.id}/validations`, {
headers: getHeaders(),
})
validations.value = (res as any[]) || []
} catch (err: any) {
console.error('Failed to fetch validations:', err)
validations.value = []
} finally {
validationsLoading.value = false
}
}
async function fetchHistory() {
historyLoading.value = true
try {
const res = await $fetch(`/api/v1/admin/providers/${route.params.id}/history`, {
headers: getHeaders(),
})
history.value = (res as any[]) || []
} catch (err: any) {
console.error('Failed to fetch history:', err)
history.value = []
} finally {
historyLoading.value = false
}
}
// ── Moderation actions ─────────────────────────────────
async function approveProvider() {
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}/approve`, {
method: 'POST',
headers: getHeaders(),
})
showToast('Szolgáltató jóváhagyva!', 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba a jóváhagyás során', 'error')
} finally {
actionLoading.value = false
}
}
async function rejectProvider() {
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}/reject`, {
method: 'POST',
headers: getHeaders(),
body: { reason: rejectReason.value || undefined },
})
showRejectModal.value = false
rejectReason.value = ''
showToast('Szolgáltató elutasítva!', 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba az elutasítás során', 'error')
} finally {
actionLoading.value = false
}
}
async function restoreProvider() {
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}/restore`, {
method: 'POST',
headers: getHeaders(),
body: {},
})
showToast('Szolgáltató visszaállítva!', 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba a visszaállítás során', 'error')
} finally {
actionLoading.value = false
}
}
async function flagProvider() {
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}/flag`, {
method: 'POST',
headers: getHeaders(),
body: { reason: flagReason.value || undefined },
})
showFlagModal.value = false
flagReason.value = ''
showToast('Szolgáltató megjelölve!', 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba a megjelölés során', 'error')
} finally {
actionLoading.value = false
}
}
async function deleteProvider() {
if (!confirm('Biztosan törlöd ezt a szolgáltatót? Ez a művelet nem visszavonható.')) return
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}`, {
method: 'DELETE',
headers: getHeaders(),
})
showToast('Szolgáltató törölve!', 'success')
router.push('/providers')
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba a törlés során', 'error')
} finally {
actionLoading.value = false
}
}
// ── Status Edit (PATCH) ────────────────────────────────
async function saveStatusEdit() {
statusLoading.value = true
try {
const body: Record<string, any> = {
status: statusForm.value.status,
validation_score: statusForm.value.validation_score,
}
if (statusForm.value.reason) {
body.reason = statusForm.value.reason
}
await $fetch(`/api/v1/admin/providers/${route.params.id}`, {
method: 'PATCH',
headers: getHeaders(),
body,
})
showToast('Státusz frissítve!', 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || 'Hiba a státusz mentése során', 'error')
} finally {
statusLoading.value = false
}
}
// ── UI helpers ─────────────────────────────────────────
function openRejectModal() {
rejectReason.value = ''
showRejectModal.value = true
}
function openFlagModal() {
flagReason.value = ''
showFlagModal.value = true
}
function goBack() {
router.push('/providers')
}
function showToast(message: string, type: 'success' | 'error') {
toast.value = { message, type }
setTimeout(() => { toast.value = null }, 4000)
}
function resetStatusForm() {
if (provider.value) {
statusForm.value.status = provider.value.status || 'pending'
statusForm.value.validation_score = provider.value.validation_score || 0
}
statusForm.value.reason = ''
}
// ── Tab change watcher ─────────────────────────────────
function onTabChange(tab: string) {
if (tab === 'validations' && validations.value.length === 0) {
fetchValidations()
}
if (tab === 'history' && history.value.length === 0) {
fetchHistory()
}
}
watch(activeTab, onTabChange)
// ── Lifecycle ──────────────────────────────────────────
onMounted(() => {
fetchProvider()
})
</script>
<style scoped>
/* Custom range slider styling */
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
border: 2px solid #1e293b;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
border: 2px solid #1e293b;
}
/* Timeline animation */
.timeline-enter-active {
transition: all 0.3s ease-out;
}
.timeline-enter-from {
opacity: 0;
transform: translateX(-10px);
}
</style>