1416 lines
67 KiB
Vue
1416 lines
67 KiB
Vue
<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> |