Files
service-finder/frontend_admin/pages/providers/[id]/index.vue
2026-07-08 08:03:57 +00:00

1416 lines
67 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>
{{ $t('providers.detail_back') }}
</button>
<h1 class="text-2xl font-bold text-white">{{ $t('providers.provider_detail') }}</h1>
<p class="text-slate-400 mt-1">{{ provider?.name || $t('providers.detail_provider_number', { id: $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>{{ $t('providers.detail_loading') }}</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">{{ $t('providers.error') }}</p>
<button @click="fetchProvider" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ $t('providers.retry') }}</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">{{ $t('providers.detail_not_found') }}</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">{{ $t(statusLabel(provider.status)) }}</p>
<p class="text-xs mt-0.5" :class="statusSubtextClass">{{ $t(statusDescription(provider.status)) }}</p>
</div>
</div>
<!-- Tab Navigation + Raw Data Drawer Toggle -->
<div class="border-b border-slate-700 mb-6 flex items-center justify-between">
<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" />
{{ $t(tab.label) }}
</button>
</nav>
<button
@click="isRawDataDrawerOpen = !isRawDataDrawerOpen"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition"
:class="isRawDataDrawerOpen
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/25'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white'"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
🤖 {{ $t('providers.detail_raw_data') }}
</button>
</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">{{ $t('providers.detail_section_basic') }}</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_name') }}</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">{{ $t('providers.detail_field_category') }}</dt>
<dd class="text-sm text-white mt-1">{{ provider.category || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_source') }}</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">{{ $t('providers.detail_field_validation_score') }}</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">{{ $t('providers.detail_field_created') }}</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">{{ $t('providers.detail_field_submitted_by') }}</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">{{ $t('providers.detail_section_address') }}</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">{{ $t('providers.detail_field_full_address') }}</dt>
<dd class="text-sm text-white mt-1">
{{ provider.address_detail
? `${[provider.address_detail.zip, provider.address_detail.city, provider.address_detail.street_name, provider.address_detail.street_type, provider.address_detail.house_number].filter(Boolean).join(' ')}`.trim() || provider.address_detail.full_address_text || '—'
: '—'
}}
</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_city') }}</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_detail?.city || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_zip') }}</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_detail?.zip || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_street') }}</dt>
<dd class="text-sm text-white mt-1">{{ [provider.address_detail?.street_name, provider.address_detail?.street_type].filter(Boolean).join(' ') || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_house_number') }}</dt>
<dd class="text-sm text-white mt-1">{{ provider.address_detail?.house_number || '—' }}</dd>
</div>
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_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">{{ $t('providers.detail_section_contact') }}</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('providers.detail_field_phone') }}</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">{{ $t('providers.detail_field_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">{{ $t('providers.detail_field_website') }}</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) P0 REFACTOR: Label-based display -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.detail_section_services') }}</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"
>
{{ getCategoryName(catId) }}
</span>
</div>
<p v-else class="text-sm text-slate-500 italic">{{ $t('providers.detail_no_categories') }}</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">{{ $t('providers.detail_section_vehicle_classes') }}</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"
>
{{ $t(vehicleClassLabel(vc)) }}
</span>
</div>
<p v-else class="text-sm text-slate-500 italic">{{ $t('providers.detail_no_vehicle_classes') }}</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">{{ $t('providers.detail_section_specializations') }}</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">{{ $t('providers.detail_specialization_brands') }}</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">{{ $t('providers.detail_specialization_propulsion') }}</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">{{ $t('providers.detail_no_specializations') }}</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">{{ $t('providers.detail_section_opening_hours') }}</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">{{ $t('providers.detail_day_label') }}</th>
<th class="text-left text-xs text-slate-500 uppercase tracking-wider pb-2">{{ $t('providers.detail_hours_label') }}</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">{{ $t(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">{{ $t('providers.detail_closed') }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<p class="text-sm text-slate-500 italic">{{ $t('providers.detail_no_opening_hours') }}</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">{{ $t('providers.detail_section_moderation') }}</h2>
<!-- Pending / Research In Progress Actions -->
<div v-if="provider.status === 'pending' || provider.status === 'research_in_progress'" 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>
{{ $t('providers.detail_edit') }}
</NuxtLink>
<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>
{{ $t('providers.detail_approve') }}
</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>
{{ $t('providers.detail_reject') }}
</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>
{{ $t('providers.detail_edit') }}
</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>
{{ $t('providers.detail_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('providers.detail_delete') }}
</button>
</div>
<!-- Rejected / Flagged Actions -->
<div v-else class="space-y-3">
<p class="text-sm text-slate-400 text-center py-2">
{{ $t('providers.detail_status_rejected_or_flagged', { action: provider.status === 'rejected' ? $t('providers.detail_status_rejected_text') : $t('providers.detail_status_flagged_text') }) }}
</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>
{{ $t('providers.detail_edit') }}
</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>
{{ $t('providers.detail_restore') }}
</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('providers.detail_delete') }}
</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">{{ $t('providers.detail_section_evidence') }}</h2>
<img
:src="provider.evidence_image_path"
:alt="$t('providers.detail_section_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">{{ $t('providers.detail_section_status_management') }}</h2>
<p class="text-sm text-slate-400 mb-6">{{ $t('providers.detail_status_form_desc') }}</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">{{ $t('providers.detail_status_form_label') }}</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">{{ $t('providers.detail_status_pending') }}</option>
<option value="approved">{{ $t('providers.detail_status_approved') }}</option>
<option value="rejected">{{ $t('providers.detail_status_rejected') }}</option>
</select>
</div>
<!-- Validation Score -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">
{{ $t('providers.detail_status_form_score') }}
</label>
<div class="flex items-center gap-3">
<input
v-model.number="statusForm.validation_score"
type="number"
min="0"
max="100"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
</div>
</div>
<!-- Reason -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.detail_status_form_reason') }}</label>
<textarea
v-model="statusForm.reason"
rows="3"
:placeholder="$t('providers.detail_status_form_reason_placeholder')"
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">{{ $t('providers.detail_status_form_cancel') }}</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>
{{ $t('providers.detail_status_form_save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Tab: Validations -->
<div v-if="activeTab === 'validations'">
<!-- P0 FIX: Validation Score Hero Card -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">{{ $t('providers.detail_section_validation_score') }}</h2>
<span
class="text-xs font-medium px-2.5 py-1 rounded-full"
:class="provider.validation_score >= 80 ? 'bg-emerald-500/20 text-emerald-400' : provider.validation_score >= 50 ? 'bg-amber-500/20 text-amber-400' : 'bg-rose-500/20 text-rose-400'"
>
{{ provider.validation_score >= 80 ? $t('providers.detail_validation_score_excellent') : provider.validation_score >= 50 ? $t('providers.detail_validation_score_medium') : $t('providers.detail_validation_score_weak') }}
</span>
</div>
<div class="flex items-center gap-6">
<div class="relative w-24 h-24 flex-shrink-0">
<svg class="w-24 h-24 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="currentColor" stroke-width="3" class="text-slate-700" />
<circle
cx="18" cy="18" r="15.5" fill="none" stroke="currentColor" stroke-width="3"
:stroke-dasharray="100"
:stroke-dashoffset="100 - Math.min(provider.validation_score, 100)"
class="transition-all duration-1000 ease-out"
:class="provider.validation_score >= 80 ? 'text-emerald-500' : provider.validation_score >= 50 ? 'text-amber-500' : 'text-rose-500'"
stroke-linecap="round"
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-2xl font-bold text-white">{{ provider.validation_score }}</span>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-32 h-2.5 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="provider.validation_score >= 80 ? 'bg-emerald-500' : provider.validation_score >= 50 ? 'bg-amber-500' : 'bg-rose-500'"
:style="{ width: Math.min(provider.validation_score, 100) + '%' }"
/>
</div>
<span class="text-sm text-slate-400">/ 100</span>
</div>
<p class="text-xs text-slate-500">{{ $t('providers.detail_validation_score_desc') }}</p>
</div>
</div>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.detail_section_gamification_validations') }}</h2>
<p class="text-sm text-slate-400 mb-6">{{ $t('providers.detail_validation_list_desc') }}</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">{{ $t('providers.detail_validation_no_entries') }}</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">{{ $t('providers.detail_validation_weight') }}: {{ v.weight }}</span>
</div>
<p class="text-sm text-slate-300">
<span class="text-slate-400">{{ $t('providers.detail_validation_voter') }}:</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">
{{ $t('providers.detail_validation_reason') }}: {{ 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: Crawler / Nyers Adatok -->
<div v-if="activeTab === 'crawler'" class="max-w-4xl">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.detail_section_crawler_data') }}</h2>
<p class="text-sm text-slate-400 mb-6">{{ $t('providers.detail_crawler_desc') }}</p>
<!-- Badges Row -->
<div class="flex flex-wrap gap-3 mb-6">
<!-- Source Badge -->
<div class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 rounded-lg text-xs font-medium">
<span class="text-slate-400">{{ $t('providers.detail_crawler_source') }}:</span>
<code class="text-cyan-300 font-mono">{{ provider.source || '—' }}</code>
</div>
<!-- Trust Score Badge -->
<div
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
:class="trustScoreBadgeClass"
>
<span class="text-slate-400">{{ $t('providers.detail_crawler_trust_score') }}:</span>
<span class="font-semibold">{{ provider.trust_score ?? '—' }}</span>
</div>
</div>
<!-- Rejection Reason Alert -->
<div
v-if="provider.rejection_reason"
class="flex items-start gap-3 p-4 mb-6 bg-rose-500/10 border border-rose-500/30 rounded-lg"
>
<svg class="w-5 h-5 text-rose-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<div>
<p class="text-sm font-medium text-rose-300">{{ $t('providers.detail_crawler_rejection_reason_title') }}</p>
<p class="text-sm text-rose-200/70 mt-0.5">{{ provider.rejection_reason }}</p>
</div>
</div>
<!-- Raw Data Viewer -->
<div class="mb-6">
<h3 class="text-sm font-medium text-slate-300 mb-3">{{ $t('providers.detail_crawler_raw_data') }}</h3>
<div class="bg-slate-900/50 rounded-lg border border-slate-700 p-4 overflow-x-auto">
<RawDataViewer :data="provider.raw_data" />
</div>
</div>
<!-- Audit Trail -->
<div v-if="provider.audit_trail && Object.keys(provider.audit_trail).length > 0">
<h3 class="text-sm font-medium text-slate-300 mb-3">{{ $t('providers.detail_crawler_audit_trail') }}</h3>
<div class="bg-slate-900/50 rounded-lg border border-slate-700 p-4 overflow-x-auto">
<RawDataViewer :data="provider.audit_trail" />
</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">{{ $t('providers.detail_section_history') }}</h2>
<p class="text-sm text-slate-400 mb-6">{{ $t('providers.detail_history_desc') }}</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">{{ $t('providers.detail_history_no_entries') }}</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">
{{ $t('providers.detail_history_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">{{ $t('providers.detail_history_status') }}:</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">{{ $t('providers.detail_history_reason') }}:</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">{{ $t('providers.detail_history_detailed_changes') }}</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">{{ $t('providers.detail_history_old_data') }}</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">{{ $t('providers.reject_modal_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
{{ $t('providers.reject_modal_body', { name: provider?.name }) }}
</p>
<form @submit.prevent="rejectProvider">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.reject_reason_label') }}</label>
<textarea
v-model="rejectReason"
rows="3"
:placeholder="$t('providers.reject_reason_placeholder')"
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">{{ $t('common.cancel') }}</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>
{{ $t('providers.reject') }}
</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">{{ $t('providers.detail_flag_modal_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
{{ $t('providers.detail_flag_modal_body', { name: provider?.name }) }}
</p>
<form @submit.prevent="flagProvider">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.detail_flag_reason_label') }}</label>
<textarea
v-model="flagReason"
rows="3"
:placeholder="$t('providers.detail_flag_reason_placeholder')"
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">{{ $t('common.cancel') }}</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>
{{ $t('providers.detail_flag_submit') }}
</button>
</div>
</form>
</div>
</div>
<!-- Raw Data Side Drawer (Slide-over) - Non-modal, Resizable -->
<Teleport to="body">
<div
v-if="isRawDataDrawerOpen"
class="fixed inset-y-0 right-0 bg-slate-900 border-l border-slate-700 shadow-2xl z-40 overflow-y-auto"
:style="{ width: drawerWidth + 'px', minWidth: '320px', maxWidth: '80vw' }"
>
<!-- Drag Handle (left edge) -->
<div
class="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary-500/50 active:bg-primary-500/70 transition-colors z-50 group"
@mousedown.prevent="startResize"
>
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-0.5 h-8 rounded-full bg-slate-600 group-hover:bg-primary-400 transition-colors"></div>
</div>
<!-- Drawer Header -->
<div class="sticky top-0 z-10 bg-slate-900/95 backdrop-blur-sm border-b border-slate-700 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h2 class="text-lg font-semibold text-white">{{ $t('providers.detail_raw_data') }}</h2>
</div>
<button
@click="isRawDataDrawerOpen = false"
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>
<!-- Drawer Content -->
<div class="p-6 space-y-6">
<!-- Source & Trust Score Badges -->
<div class="flex flex-wrap gap-3">
<div class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 rounded-lg text-xs font-medium">
<span class="text-slate-400">{{ $t('providers.detail_crawler_source') }}:</span>
<code class="text-cyan-300 font-mono">{{ provider?.source || '—' }}</code>
</div>
<div
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
:class="trustScoreBadgeClass"
>
<span class="text-slate-400">{{ $t('providers.detail_crawler_trust_score') }}:</span>
<span class="font-semibold">{{ provider?.trust_score ?? '—' }}</span>
</div>
</div>
<!-- Raw Data Viewer -->
<div>
<h3 class="text-sm font-medium text-slate-300 mb-3">{{ $t('providers.detail_crawler_raw_data') }}</h3>
<div class="bg-slate-800/50 rounded-lg border border-slate-700 p-4 overflow-x-auto">
<RawDataViewer :data="provider?.raw_data" />
</div>
</div>
<!-- Audit Trail -->
<div v-if="provider?.audit_trail && Object.keys(provider.audit_trail).length > 0">
<h3 class="text-sm font-medium text-slate-300 mb-3">{{ $t('providers.detail_crawler_audit_trail') }}</h3>
<div class="bg-slate-800/50 rounded-lg border border-slate-700 p-4 overflow-x-auto">
<RawDataViewer :data="provider?.audit_trail" />
</div>
</div>
<!-- Rejection Reason Alert -->
<div
v-if="provider?.rejection_reason"
class="flex items-start gap-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg"
>
<svg class="w-5 h-5 text-rose-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<div>
<p class="text-sm font-medium text-rose-300">{{ $t('providers.detail_crawler_rejection_reason_title') }}</p>
<p class="text-sm text-rose-200/70 mt-0.5">{{ provider.rejection_reason }}</p>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCategories } from '~/composables/useCategories'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// ── Global Category Store ──────────────────────────────
const { getCategoryName, isLoading: catLoading } = useCategories()
// ── State ──────────────────────────────────────────────
const loading = ref(true)
const error = ref(false)
const provider = ref<any>(null)
const actionLoading = ref(false)
// Raw Data Side Drawer
const isRawDataDrawerOpen = ref(false)
/**
* P0 FEATURE (2026-07-07): Resizable Raw Data Drawer
* ====================================================
* The drawer can be resized by dragging its left edge.
* Default width: 512px. Min: 320px. Max: 80vw.
* Mouse events attach to window on drag start and detach on drag end.
*/
const drawerWidth = ref(512)
const isResizing = ref(false)
function startResize(e: MouseEvent) {
isResizing.value = true
// Record initial mouse X and drawer width for delta calculation
;(window as any).__resizeStartX = e.clientX
;(window as any).__resizeStartWidth = drawerWidth.value
window.addEventListener('mousemove', doResize)
window.addEventListener('mouseup', stopResize)
// Prevent text selection during resize
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}
function doResize(e: MouseEvent) {
if (!isResizing.value) return
const startX = (window as any).__resizeStartX
const startWidth = (window as any).__resizeStartWidth
// Calculate delta: mouse moving left = negative = smaller drawer
const delta = startX - e.clientX
const newWidth = startWidth + delta
// Clamp between minWidth (320) and maxWidth (80vw)
const maxW = window.innerWidth * 0.8
drawerWidth.value = Math.max(320, Math.min(maxW, newWidth))
}
function stopResize() {
isResizing.value = false
window.removeEventListener('mousemove', doResize)
window.removeEventListener('mouseup', stopResize)
document.body.style.userSelect = ''
document.body.style.cursor = ''
;(window as any).__resizeStartX = null
;(window as any).__resizeStartWidth = null
}
// 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' | 'crawler'>('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: 'providers.detail_tab_data', icon: 'svg' },
{ key: 'status', label: 'providers.detail_tab_status', icon: 'svg' },
{ key: 'validations', label: 'providers.detail_tab_validations', icon: 'svg' },
{ key: 'crawler', label: 'providers.detail_tab_crawler', icon: 'svg' },
{ key: 'history', label: 'providers.detail_tab_history', 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'
case 'research_in_progress': return 'bg-blue-500/10 border-blue-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'
case 'research_in_progress': return 'bg-blue-500/20 text-blue-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'
case 'research_in_progress': return 'text-blue-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'
case 'research_in_progress': return 'text-blue-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)
})
/**
* Trust score badge color based on value.
* Green if > 80, yellow if > 50, red if <= 50.
*/
const trustScoreBadgeClass = computed(() => {
const score = provider.value?.trust_score
if (score === null || score === undefined) return 'bg-slate-700 text-slate-400'
if (score > 80) return 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
if (score > 50) return 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
return 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
})
// ── DAYS constant ──────────────────────────────────────
const DAYS = [
{ key: 'monday', label: 'providers.detail_day_monday' },
{ key: 'tuesday', label: 'providers.detail_day_tuesday' },
{ key: 'wednesday', label: 'providers.detail_day_wednesday' },
{ key: 'thursday', label: 'providers.detail_day_thursday' },
{ key: 'friday', label: 'providers.detail_day_friday' },
{ key: 'saturday', label: 'providers.detail_day_saturday' },
{ key: 'sunday', label: 'providers.detail_day_sunday' },
]
/**
* 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 'providers.detail_status_approved'
case 'pending': return 'providers.detail_status_pending'
case 'research_in_progress': return 'providers.detail_status_research'
case 'rejected': return 'providers.detail_status_rejected'
default: return 'providers.detail_status_unknown'
}
}
function statusDescription(status: string): string {
switch (status) {
case 'approved': return 'providers.detail_status_desc_approved'
case 'pending': return 'providers.detail_status_desc_pending'
case 'research_in_progress': return 'providers.detail_status_desc_research'
case 'rejected': return 'providers.detail_status_desc_rejected'
default: return ''
}
}
/**
* Maps vehicle class keys to i18n labels.
*/
function vehicleClassLabel(key: string): string {
const labels: Record<string, string> = {
car: 'providers.detail_vehicle_class_car',
motorcycle: 'providers.detail_vehicle_class_motorcycle',
truck: 'providers.detail_vehicle_class_truck',
hgv: 'providers.detail_vehicle_class_hgv',
light_commercial: 'providers.detail_vehicle_class_light_commercial',
personal: 'providers.detail_vehicle_class_personal',
boat: 'providers.detail_vehicle_class_boat',
other: 'providers.detail_vehicle_class_other',
}
return labels[key] || key
}
function validationTypeLabel(type: string): string {
const labels: Record<string, string> = {
phone_verified: 'providers.detail_validation_type_phone_verified',
email_verified: 'providers.detail_validation_type_email_verified',
address_verified: 'providers.detail_validation_type_address_verified',
website_verified: 'providers.detail_validation_type_website_verified',
manual_review: 'providers.detail_validation_type_manual_review',
community_vouch: 'providers.detail_validation_type_community_vouch',
business_hours: 'providers.detail_validation_type_business_hours',
photo_evidence: 'providers.detail_validation_type_photo_evidence',
}
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: 'providers.detail_history_action_approve',
reject: 'providers.detail_history_action_reject',
restore: 'providers.detail_history_action_restore',
flag: 'providers.detail_history_action_flag',
update: 'providers.detail_history_action_update',
delete: 'providers.detail_history_action_delete',
create: 'providers.detail_history_action_create',
}
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(t('providers.detail_toast_approved'), 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || t('providers.detail_toast_error_approve'), '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(t('providers.detail_toast_rejected'), 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || t('providers.detail_toast_error_reject'), '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(t('providers.detail_toast_restored'), 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || t('providers.detail_toast_error_restore'), '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(t('providers.detail_toast_flagged'), 'success')
await fetchProvider()
} catch (err: any) {
showToast(err?.data?.detail || t('providers.detail_toast_error_flag'), 'error')
} finally {
actionLoading.value = false
}
}
async function deleteProvider() {
if (!confirm(t('providers.detail_delete_confirm'))) return
actionLoading.value = true
try {
await $fetch(`/api/v1/admin/providers/${route.params.id}`, {
method: 'DELETE',
headers: getHeaders(),
})
showToast(t('providers.detail_toast_deleted'), 'success')
router.push('/providers')
} catch (err: any) {
showToast(err?.data?.detail || t('providers.detail_toast_error_delete'), '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>
/* Timeline animation */
.timeline-enter-active {
transition: all 0.3s ease-out;
}
.timeline-enter-from {
opacity: 0;
transform: translateX(-10px);
}
</style>