1326 lines
59 KiB
Vue
1326 lines
59 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.edit_back') }}
|
||
</button>
|
||
<h1 class="text-2xl font-bold text-white">{{ $t('providers.edit_title') }}</h1>
|
||
<p class="text-slate-400 mt-1">{{ provider?.name || $t('providers.detail_provider_number', { id: providerId }) }}</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.edit_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.edit_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.edit_not_found') }}</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- 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" />
|
||
{{ 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.edit_raw_data') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
TAB 1: Alapadatok & Kapcsolat (Basic Info & Contact)
|
||
═══════════════════════════════════════════════════ -->
|
||
<div v-if="activeTab === 'basic'" class="max-w-3xl">
|
||
<form @submit.prevent="saveForm">
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.edit_section_basic') }}</h2>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<!-- Name -->
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_name') }}</label>
|
||
<input
|
||
v-model="form.name"
|
||
required
|
||
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"
|
||
/>
|
||
</div>
|
||
<!-- Category -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_category') }}</label>
|
||
<input
|
||
v-model="form.category"
|
||
:placeholder="$t('providers.edit_field_category_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"
|
||
/>
|
||
</div>
|
||
<!-- Source (read-only) -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_source') }}</label>
|
||
<div class="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-slate-400 text-sm">
|
||
{{ provider.source }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Contact Information -->
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.edit_section_contact') }}</h2>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_phone') }}</label>
|
||
<input
|
||
v-model="form.contact_phone"
|
||
:placeholder="$t('providers.edit_field_phone_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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_email') }}</label>
|
||
<input
|
||
v-model="form.contact_email"
|
||
type="email"
|
||
:placeholder="$t('providers.edit_field_email_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"
|
||
/>
|
||
</div>
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_website') }}</label>
|
||
<input
|
||
v-model="form.website"
|
||
:placeholder="$t('providers.edit_field_website_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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
TAB 2: Szolgáltatások & Járművek (The 3D Matrix)
|
||
═══════════════════════════════════════════════════ -->
|
||
<div v-if="activeTab === 'matrix'" class="max-w-4xl">
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||
<h2 class="text-lg font-semibold text-white mb-2">{{ $t('providers.edit_section_matrix') }}</h2>
|
||
<p class="text-sm text-slate-400 mb-6">
|
||
{{ $t('providers.edit_section_matrix_desc') }}
|
||
</p>
|
||
|
||
<!-- ── Dimension A: Vehicle Classes ── -->
|
||
<div class="mb-8">
|
||
<h3 class="text-md font-medium text-white mb-3 flex items-center gap-2">
|
||
<span class="w-6 h-6 rounded-full bg-indigo-500/20 text-indigo-400 flex items-center justify-center text-xs font-bold">A</span>
|
||
{{ $t('providers.edit_section_vehicle_classes') }}
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">{{ $t('providers.edit_section_vehicle_classes_desc') }}</p>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<label
|
||
v-for="vc in VEHICLE_CLASSES"
|
||
:key="vc.key"
|
||
class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition"
|
||
:class="form.supported_vehicle_classes.includes(vc.key)
|
||
? 'bg-indigo-500/10 border-indigo-500/40 text-indigo-300'
|
||
: 'bg-slate-700/50 border-slate-600 hover:border-slate-500 text-slate-300'"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:value="vc.key"
|
||
:checked="form.supported_vehicle_classes.includes(vc.key)"
|
||
@change="toggleVehicleClass(vc.key)"
|
||
class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50"
|
||
/>
|
||
<span class="text-sm">{{ vc.label }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Dimension B: Service Categories (Expertise Tags) ── -->
|
||
<div class="mb-8">
|
||
<h3 class="text-md font-medium text-white mb-3 flex items-center gap-2">
|
||
<span class="w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-bold">B</span>
|
||
{{ $t('providers.edit_section_expertise_tags') }}
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">{{ $t('providers.edit_section_expertise_tags_desc') }}</p>
|
||
|
||
<!-- Loading tree -->
|
||
<div v-if="treeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-4">
|
||
<svg 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>
|
||
<span>{{ $t('providers.edit_categories_loading') }}</span>
|
||
</div>
|
||
|
||
<!-- Tree -->
|
||
<div v-else class="space-y-1 max-h-96 overflow-y-auto pr-2">
|
||
<div v-for="node in categoryTree" :key="node.id" class="ml-0">
|
||
<!-- Level 0 node (root) -->
|
||
<div class="flex items-center gap-2 py-1">
|
||
<button
|
||
@click="toggleTreeNode(node.id)"
|
||
class="text-slate-500 hover:text-slate-300 transition w-4 h-4 flex items-center justify-center"
|
||
>
|
||
<svg
|
||
class="w-3 h-3 transition-transform"
|
||
:class="expandedNodes.has(node.id) ? 'rotate-90' : ''"
|
||
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>
|
||
</button>
|
||
<label class="flex items-center gap-2 cursor-pointer flex-1">
|
||
<input
|
||
type="checkbox"
|
||
:checked="isCategorySelected(node.id)"
|
||
@change="toggleCategory(node.id)"
|
||
class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50"
|
||
/>
|
||
<span class="text-sm font-medium text-white">{{ resolveNodeLabel(node) }}</span>
|
||
<span class="text-xs text-slate-500">{{ $t('providers.edit_level_label', { level: node.level }) }}</span>
|
||
</label>
|
||
</div>
|
||
<!-- Children (recursive) -->
|
||
<div v-if="expandedNodes.has(node.id) && node.children?.length" class="ml-6 border-l border-slate-700 pl-3">
|
||
<div v-for="child in node.children" :key="child.id">
|
||
<TreeNode
|
||
:node="child"
|
||
:selected-ids="form.category_ids"
|
||
:expanded-ids="expandedNodes"
|
||
@toggle="toggleCategory"
|
||
@toggle-expand="toggleTreeNode"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Selected categories summary -->
|
||
<div v-if="form.category_ids.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||
<span
|
||
v-for="id in form.category_ids"
|
||
:key="id"
|
||
class="inline-flex items-center gap-1 px-2 py-1 bg-indigo-500/10 text-indigo-300 rounded-full text-xs"
|
||
>
|
||
{{ getCategoryName(id) }}
|
||
<button @click="removeCategory(id)" class="hover:text-white transition">
|
||
<svg class="w-3 h-3" 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>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Dimension C: Specializations ── -->
|
||
<div class="mb-4">
|
||
<h3 class="text-md font-medium text-white mb-3 flex items-center gap-2">
|
||
<span class="w-6 h-6 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center text-xs font-bold">C</span>
|
||
{{ $t('providers.edit_section_specializations') }}
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">{{ $t('providers.edit_section_specializations_desc') }}</p>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- Brands -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_brands') }}</label>
|
||
<div class="flex flex-wrap gap-2 mb-2">
|
||
<!-- P0 BUGFIX: Use optional chaining + fallback to empty array -->
|
||
<span
|
||
v-for="(brand, idx) in form.specializations?.brands || []"
|
||
:key="idx"
|
||
class="inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-300 rounded-full text-xs"
|
||
>
|
||
{{ brand }}
|
||
<button @click="removeBrand(idx)" class="hover:text-white transition">
|
||
<svg class="w-3 h-3" 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>
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model="brandInput"
|
||
@keydown.enter.prevent="addBrand"
|
||
:placeholder="$t('providers.edit_field_brands_placeholder')"
|
||
class="flex-1 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"
|
||
/>
|
||
<button
|
||
@click="addBrand"
|
||
:disabled="!brandInput.trim()"
|
||
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition"
|
||
>
|
||
{{ $t('providers.edit_field_add') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Propulsion -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_propulsion') }}</label>
|
||
<div class="flex flex-wrap gap-2 mb-2">
|
||
<!-- P0 BUGFIX: Use optional chaining + fallback to empty array -->
|
||
<span
|
||
v-for="(prop, idx) in form.specializations?.propulsion || []"
|
||
:key="idx"
|
||
class="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/10 text-emerald-300 rounded-full text-xs"
|
||
>
|
||
{{ prop }}
|
||
<button @click="removePropulsion(idx)" class="hover:text-white transition">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model="propulsionInput"
|
||
@keydown.enter.prevent="addPropulsion"
|
||
:placeholder="$t('providers.edit_field_propulsion_placeholder')"
|
||
class="flex-1 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"
|
||
/>
|
||
<button
|
||
@click="addPropulsion"
|
||
:disabled="!propulsionInput.trim()"
|
||
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition"
|
||
>
|
||
{{ $t('providers.edit_field_add') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
TAB 3: Helyszín & Lokáció (Location)
|
||
═══════════════════════════════════════════════════ -->
|
||
<div v-if="activeTab === 'location'" class="max-w-3xl">
|
||
<form @submit.prevent="saveForm">
|
||
<!-- Smart Address Form (autocomplete zip/city + structured fields) -->
|
||
<!-- P0 BUGFIX: v-if guard ensures form.address is always an object before passing to child -->
|
||
<SharedAddressForm v-if="form.address" v-model="form.address" />
|
||
|
||
<!-- Additional location fields -->
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||
<h2 class="text-lg font-semibold text-white mb-4">{{ $t('providers.edit_section_location_extra') }}</h2>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_full_address') }}</label>
|
||
<input
|
||
v-model="form.address.full_address_text"
|
||
:placeholder="$t('providers.edit_field_full_address_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"
|
||
/>
|
||
</div>
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('providers.edit_field_plus_code') }}</label>
|
||
<input
|
||
v-model="form.plus_code"
|
||
:placeholder="$t('providers.edit_field_plus_code_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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
TAB 4: Nyitvatartás (Opening Hours)
|
||
═══════════════════════════════════════════════════ -->
|
||
<div v-if="activeTab === 'hours'" class="max-w-3xl">
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||
<h2 class="text-lg font-semibold text-white mb-2">{{ $t('providers.edit_section_opening_hours') }}</h2>
|
||
<p class="text-sm text-slate-400 mb-6">
|
||
{{ $t('providers.edit_section_opening_hours_desc') }}
|
||
</p>
|
||
|
||
<!-- Always-open toggle -->
|
||
<div class="mb-6 p-4 rounded-lg border bg-indigo-500/5 border-indigo-500/20">
|
||
<label class="flex items-center gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
v-model="form.is_always_open"
|
||
@change="onAlwaysOpenChange"
|
||
class="w-5 h-5 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-white">{{ $t('providers.edit_always_open') }}</span>
|
||
<p class="text-xs text-slate-400 mt-0.5">{{ $t('providers.edit_always_open_desc') }}</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Quick action buttons (disabled when always-open is active) -->
|
||
<div class="flex flex-wrap gap-2 mb-6" :class="{ 'opacity-50 pointer-events-none': form.is_always_open }">
|
||
<button
|
||
@click="setAllDaysOpen('00:00', '23:59')"
|
||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-emerald-600/30 text-emerald-400 hover:bg-emerald-600/10 transition"
|
||
>
|
||
{{ $t('providers.edit_btn_24_7') }}
|
||
</button>
|
||
<button
|
||
@click="copyWeekdays"
|
||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-indigo-600/30 text-indigo-400 hover:bg-indigo-600/10"
|
||
>
|
||
{{ $t('providers.edit_btn_copy_weekdays') }}
|
||
</button>
|
||
<button
|
||
@click="clearAllHours"
|
||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-rose-600/30 text-rose-400 hover:bg-rose-600/10"
|
||
>
|
||
{{ $t('providers.edit_btn_clear_all') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 7-day grid (disabled when always-open is active) -->
|
||
<div class="space-y-3" :class="{ 'opacity-50 pointer-events-none': form.is_always_open }">
|
||
<div
|
||
v-for="day in DAYS"
|
||
:key="day.key"
|
||
class="flex items-center gap-4 p-4 rounded-lg border"
|
||
:class="form.opening_hours?.[day.key]
|
||
? 'bg-indigo-500/5 border-indigo-500/20'
|
||
: 'bg-slate-700/30 border-slate-600'"
|
||
>
|
||
<!-- Day name + checkbox -->
|
||
<label class="flex items-center gap-2 w-36 flex-shrink-0 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
:checked="!!form.opening_hours?.[day.key]"
|
||
@change="toggleDay(day.key)"
|
||
:disabled="form.is_always_open"
|
||
class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50"
|
||
/>
|
||
<span class="text-sm font-medium text-white">{{ day.label }}</span>
|
||
</label>
|
||
|
||
<!-- Time inputs (visible only when checked) -->
|
||
<template v-if="form.opening_hours?.[day.key]">
|
||
<div class="flex items-center gap-2">
|
||
<input
|
||
type="time"
|
||
:value="form.opening_hours?.[day.key]?.open || '08:00'"
|
||
@input="updateDayTime(day.key, 'open', ($event.target as HTMLInputElement).value)"
|
||
:disabled="form.is_always_open"
|
||
class="w-28 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 disabled:opacity-50"
|
||
/>
|
||
<span class="text-slate-500 text-sm">–</span>
|
||
<input
|
||
type="time"
|
||
:value="form.opening_hours?.[day.key]?.close || '17:00'"
|
||
@input="updateDayTime(day.key, 'close', ($event.target as HTMLInputElement).value)"
|
||
:disabled="form.is_always_open"
|
||
class="w-28 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 disabled:opacity-50"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- "Zárva" label when unchecked -->
|
||
<span v-else class="text-sm text-slate-500 italic">{{ $t('providers.edit_closed') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
SAVE BUTTON (fixed bottom bar)
|
||
═══════════════════════════════════════════════════ -->
|
||
<div class="sticky bottom-0 bg-slate-900/90 backdrop-blur-sm border-t border-slate-700 -mx-6 px-6 py-4 mt-8">
|
||
<div class="flex items-center justify-between max-w-4xl">
|
||
<div class="text-sm text-slate-400">
|
||
<span v-if="hasChanges" class="text-amber-400">{{ $t('providers.edit_has_changes') }}</span>
|
||
<span v-else class="text-slate-500">{{ $t('providers.edit_no_changes') }}</span>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button
|
||
@click="goBack"
|
||
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
|
||
>
|
||
{{ $t('providers.edit_cancel') }}
|
||
</button>
|
||
<button
|
||
@click="saveForm"
|
||
:disabled="saveLoading || !hasChanges"
|
||
class="px-6 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="saveLoading" 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.edit_save') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Toast Notification -->
|
||
<div
|
||
v-if="toast"
|
||
class="fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-xl text-sm font-medium transition-all duration-300"
|
||
:class="toast.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-rose-600 text-white'"
|
||
>
|
||
{{ toast.message }}
|
||
</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.edit_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.edit_source_label') }}</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.edit_trust_score_label') }}</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.edit_raw_data_title') }}</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.edit_audit_trail_title') }}</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.edit_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">
|
||
/**
|
||
* 🏛️ Service Provider Dedicated Edit Page
|
||
* =========================================
|
||
*
|
||
* Architectural Context (P0 - Card #384):
|
||
* ----------------------------------------
|
||
* This page replaces the missing modal-based edit system with a full-page
|
||
* 3-tab editor. It integrates with the existing PATCH /admin/providers/{id}
|
||
* endpoint which supports:
|
||
* - Basic fields: name, category, contact info
|
||
* - 3D Matrix: supported_vehicle_classes, category_ids (expertise tags),
|
||
* specializations (brands + propulsion)
|
||
* - Location: address, city, zip, street, house_number, plus_code
|
||
*
|
||
* Data Flow:
|
||
* ----------
|
||
* 1. On mount: fetch provider detail from GET /admin/providers/{id}
|
||
* 2. On mount: fetch category tree from GET /providers/categories/tree
|
||
* 3. User edits fields across 3 tabs
|
||
* 4. On save: PATCH /admin/providers/{id} with full payload
|
||
* 5. On success: navigate back to /providers/{id}
|
||
*
|
||
* Vehicle Classes (Dimension A):
|
||
* ------------------------------
|
||
* The 8 system vehicle classes matching the backend enum:
|
||
* car, motorcycle, truck, hgv, light_commercial,
|
||
* personal, boat, other
|
||
*/
|
||
|
||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useCookie } from '#app'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useCategories } from '~/composables/useCategories'
|
||
|
||
// ── i18n ────────────────────────────────────────────────
|
||
const { t } = useI18n()
|
||
|
||
// ── i18n locale (dynamic) ─────────────────────────────
|
||
let currentLocale = 'hu'
|
||
try {
|
||
const i18n = useI18n()
|
||
currentLocale = i18n.locale?.value || i18n.locale || 'hu'
|
||
} catch {
|
||
// useI18n() may throw outside of setup context; fallback to 'hu'
|
||
}
|
||
|
||
/**
|
||
* Resolve a tree node's display label using i18n JSONB dictionary.
|
||
*
|
||
* Fallback chain:
|
||
* 1. Active language in name_i18n dict
|
||
* 2. Hungarian (hu) in name_i18n dict
|
||
* 3. Deprecated name_hu field
|
||
* 4. Fallback to node.key
|
||
*/
|
||
function resolveNodeLabel(node: CategoryTreeNode): string {
|
||
// 1. Try active language in i18n dict
|
||
if (node.name_i18n?.[currentLocale]) {
|
||
return node.name_i18n[currentLocale]
|
||
}
|
||
|
||
// 2. Fallback to Hungarian in i18n dict
|
||
if (node.name_i18n?.hu) {
|
||
return node.name_i18n.hu
|
||
}
|
||
|
||
// 3. Fallback to deprecated name_hu (safe transition)
|
||
if (node.name_hu) {
|
||
return node.name_hu
|
||
}
|
||
|
||
// 4. Fallback to key
|
||
return node.key
|
||
}
|
||
|
||
// ── Types ──────────────────────────────────────────────
|
||
interface CategoryTreeNode {
|
||
id: number
|
||
key: string
|
||
name_hu?: string | null
|
||
name_en?: string | null
|
||
name_i18n?: Record<string, string> | null
|
||
level: number
|
||
path?: string | null
|
||
is_official: boolean
|
||
children: CategoryTreeNode[]
|
||
}
|
||
|
||
interface ProviderDetail {
|
||
id: number
|
||
name: string
|
||
address: string
|
||
city?: string | null
|
||
address_zip?: string | null
|
||
address_street_name?: string | null
|
||
address_street_type?: string | null
|
||
address_house_number?: string | null
|
||
plus_code?: string | null
|
||
contact_phone?: string | null
|
||
contact_email?: string | null
|
||
website?: string | null
|
||
category?: string | null
|
||
status: string
|
||
source: string
|
||
validation_score: number
|
||
evidence_image_path?: string | null
|
||
added_by_user_id?: number | null
|
||
category_ids?: number[] | null
|
||
supported_vehicle_classes?: string[] | null
|
||
specializations?: Record<string, any> | null
|
||
opening_hours?: Record<string, any> | null
|
||
address_detail?: Record<string, any> | null
|
||
created_at?: string | null
|
||
}
|
||
|
||
// ── Constants ──────────────────────────────────────────
|
||
/**
|
||
* The 8 system vehicle classes.
|
||
* These match the backend VehicleClass enum values.
|
||
*/
|
||
const VEHICLE_CLASSES = [
|
||
{ key: 'car', label: t('providers.detail_vehicle_class_car') },
|
||
{ key: 'motorcycle', label: t('providers.detail_vehicle_class_motorcycle') },
|
||
{ key: 'truck', label: t('providers.detail_vehicle_class_truck') },
|
||
{ key: 'hgv', label: t('providers.detail_vehicle_class_hgv') },
|
||
{ key: 'light_commercial', label: t('providers.detail_vehicle_class_light_commercial') },
|
||
{ key: 'personal', label: t('providers.detail_vehicle_class_personal') },
|
||
{ key: 'boat', label: t('providers.detail_vehicle_class_boat') },
|
||
{ key: 'other', label: t('providers.detail_vehicle_class_other') },
|
||
]
|
||
|
||
// ── Route ──────────────────────────────────────────────
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const providerId = computed(() => Number(route.params.id))
|
||
|
||
// ── Global Category Store (P0 REFACTOR) ────────────────
|
||
const { getCategoryName, getCategoryParentId, categoryMap, isLoaded: catLoaded } = useCategories()
|
||
|
||
// ── State ──────────────────────────────────────────────
|
||
const loading = ref(true)
|
||
const error = ref(false)
|
||
const provider = ref<ProviderDetail | null>(null)
|
||
const saveLoading = ref(false)
|
||
const toast = ref<{ message: string; type: 'success' | 'error' } | null>(null)
|
||
|
||
// 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
|
||
}
|
||
|
||
// Tab state
|
||
const activeTab = ref<'basic' | 'matrix' | 'location' | 'hours'>('basic')
|
||
const tabs = [
|
||
{ key: 'basic', label: t('providers.edit_tab_basic'), icon: 'svg' },
|
||
{ key: 'matrix', label: t('providers.edit_tab_matrix'), icon: 'svg' },
|
||
{ key: 'location', label: t('providers.edit_tab_location'), icon: 'svg' },
|
||
{ key: 'hours', label: t('providers.edit_tab_hours'), icon: 'svg' },
|
||
]
|
||
|
||
// Category tree state
|
||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||
const treeLoading = ref(false)
|
||
const expandedNodes = ref<Set<number>>(new Set())
|
||
|
||
// Tag inputs
|
||
const brandInput = ref('')
|
||
const propulsionInput = ref('')
|
||
|
||
/**
|
||
* ═══════════════════════════════════════════════════════════════
|
||
* P0 BUGFIX (2026-07-07): Deep Template & Reactive State Audit
|
||
*
|
||
* Root Cause:
|
||
* The reactive form's nested objects (specializations, opening_hours,
|
||
* address) were initialized with proper defaults, but the template
|
||
* bindings (v-for, v-model) accessed properties on these objects
|
||
* WITHOUT null-safety guards. When the API returns null for any of
|
||
* these nested fields, or when there's a timing issue between
|
||
* provider.value assignment and mapProviderToForm execution, Vue's
|
||
* template renderer crashes with "Cannot read properties of null".
|
||
*
|
||
* Fix:
|
||
* 1. All nested objects explicitly initialized with all sub-fields
|
||
* 2. Template bindings use optional chaining (?.) for all nested
|
||
* property accesses
|
||
* 3. mapProviderToForm NEVER assigns null to nested reactive objects
|
||
* 4. v-for loops guarded with fallback (|| [])
|
||
* 5. SharedAddressForm has v-if="form.address" guard
|
||
*/
|
||
|
||
// ── Reactive Form ──────────────────────────────────────
|
||
/**
|
||
* CRITICAL: All nested objects MUST be explicitly initialized with
|
||
* all sub-fields. Vue 3 reactive() does NOT deep-define properties
|
||
* that don't exist at initialization time. If any nested object is
|
||
* null/undefined at init, template bindings like v-for, v-model
|
||
* will crash with "Cannot read properties of null".
|
||
*/
|
||
const form = reactive({
|
||
name: '',
|
||
category: '',
|
||
contact_phone: '',
|
||
contact_email: '',
|
||
website: '',
|
||
plus_code: '',
|
||
status: 'active',
|
||
supported_vehicle_classes: [] as string[],
|
||
category_ids: [] as number[],
|
||
/** CRITICAL: specializations MUST be an object with brands and propulsion arrays */
|
||
specializations: {
|
||
brands: [] as string[],
|
||
propulsion: [] as string[],
|
||
} as Record<string, any>,
|
||
/** CRITICAL: opening_hours MUST be an object (not null) */
|
||
opening_hours: {} as Record<string, any>,
|
||
/** CRITICAL: address MUST be a fully initialized object */
|
||
address: {
|
||
zip: null as string | null,
|
||
city: null as string | null,
|
||
street_name: null as string | null,
|
||
street_type: null as string | null,
|
||
house_number: null as string | null,
|
||
stairwell: null as string | null,
|
||
floor: null as string | null,
|
||
door: null as string | null,
|
||
parcel_id: null as string | null,
|
||
full_address_text: null as string | null,
|
||
latitude: null as number | null,
|
||
longitude: null as number | null,
|
||
},
|
||
is_always_open: false,
|
||
})
|
||
|
||
// ── Days of the week ───────────────────────────────────
|
||
const DAYS = [
|
||
{ key: 'monday', label: t('providers.detail_day_monday') },
|
||
{ key: 'tuesday', label: t('providers.detail_day_tuesday') },
|
||
{ key: 'wednesday', label: t('providers.detail_day_wednesday') },
|
||
{ key: 'thursday', label: t('providers.detail_day_thursday') },
|
||
{ key: 'friday', label: t('providers.detail_day_friday') },
|
||
{ key: 'saturday', label: t('providers.detail_day_saturday') },
|
||
{ key: 'sunday', label: t('providers.detail_day_sunday') },
|
||
]
|
||
|
||
// ── Computed ───────────────────────────────────────────
|
||
|
||
/**
|
||
* 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'
|
||
})
|
||
|
||
const hasChanges = computed(() => {
|
||
if (!provider.value) return false
|
||
const p = provider.value
|
||
// Basic fields
|
||
if (form.name !== p.name) return true
|
||
if (form.category !== (p.category || '')) return true
|
||
if (form.contact_phone !== (p.contact_phone || '')) return true
|
||
if (form.contact_email !== (p.contact_email || '')) return true
|
||
if (form.website !== (p.website || '')) return true
|
||
if (form.plus_code !== (p.plus_code || '')) return true
|
||
|
||
// Vehicle classes
|
||
const origClasses = p.supported_vehicle_classes || []
|
||
if (JSON.stringify(form.supported_vehicle_classes.slice().sort()) !== JSON.stringify([...origClasses].sort())) return true
|
||
|
||
// P0 REFACTOR: Category IDs deep comparison with null-safety
|
||
// Edge case: original.category_ids might be null — treat as []
|
||
const origCats = p.category_ids || []
|
||
if (JSON.stringify(form.category_ids.slice().sort()) !== JSON.stringify([...origCats].sort())) return true
|
||
|
||
// Specializations
|
||
const origSpec = p.specializations || {}
|
||
const formSpec = form.specializations || {}
|
||
if (JSON.stringify(formSpec.brands || []) !== JSON.stringify(origSpec.brands || [])) return true
|
||
if (JSON.stringify(formSpec.propulsion || []) !== JSON.stringify(origSpec.propulsion || [])) return true
|
||
|
||
// Opening hours
|
||
const origHours = p.opening_hours || {}
|
||
const formHours = form.opening_hours || {}
|
||
const allDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||
for (const day of allDays) {
|
||
const o = origHours[day]
|
||
const f = formHours[day]
|
||
if (!o && !f) continue
|
||
if (!o || !f) return true
|
||
if (o.open !== f.open || o.close !== f.close) return true
|
||
}
|
||
|
||
// P0 BUGFIX: Deep compare address_detail (nested object)
|
||
// The API returns address_detail as a nested object, but the form stores
|
||
// address fields in form.address. We compare each sub-field individually.
|
||
const addr = p.address_detail || {}
|
||
const fAddr = form.address
|
||
if (fAddr.zip !== (addr.zip || addr.address_zip || null)) return true
|
||
if (fAddr.city !== (addr.city || null)) return true
|
||
if (fAddr.street_name !== (addr.street_name || addr.address_street_name || null)) return true
|
||
if (fAddr.street_type !== (addr.street_type || addr.address_street_type || null)) return true
|
||
if (fAddr.house_number !== (addr.house_number || addr.address_house_number || null)) return true
|
||
if (fAddr.stairwell !== (addr.stairwell || null)) return true
|
||
if (fAddr.floor !== (addr.floor || null)) return true
|
||
if (fAddr.door !== (addr.door || null)) return true
|
||
if (fAddr.parcel_id !== (addr.parcel_id || null)) return true
|
||
if (fAddr.full_address_text !== (addr.full_address_text || null)) return true
|
||
if (fAddr.latitude !== (addr.latitude ?? null)) return true
|
||
if (fAddr.longitude !== (addr.longitude ?? null)) return true
|
||
|
||
return false
|
||
})
|
||
|
||
// ── Category Helpers ───────────────────────────────────
|
||
function isCategorySelected(id: number): boolean {
|
||
return form.category_ids.includes(id)
|
||
}
|
||
|
||
// P0 REFACTOR: getCategoryName is now provided by the global useCategories composable.
|
||
// The local implementation has been removed to use the singleton cache.
|
||
|
||
/**
|
||
* P0 REFACTOR (2026-07-07): Parent-Child Category Automation.
|
||
*
|
||
* Uses the global useCategories composable's getCategoryParentId()
|
||
* instead of the local parentLookupMap. This eliminates code duplication
|
||
* and ensures the category hierarchy is always in sync.
|
||
*
|
||
* When a user selects a child category (e.g., "Benzinkut" at level 2),
|
||
* this function AUTOMATICALLY pushes the Parent Category ID
|
||
* (e.g., "Uzemanyag" at level 1) into form.category_ids if not already present.
|
||
*
|
||
* This eliminates the need for manual parent selection and ensures the
|
||
* backend receives a complete hierarchy for proper ServiceExpertise linking.
|
||
*/
|
||
function toggleCategory(id: number): void {
|
||
const idx = form.category_ids.indexOf(id)
|
||
if (idx === -1) {
|
||
// -- SELECT: Add the category + auto-add its parent --
|
||
form.category_ids.push(id)
|
||
// Auto-add parent category if this is a child node
|
||
const parentId = getCategoryParentId(id)
|
||
if (parentId !== null && parentId !== undefined && !form.category_ids.includes(parentId)) {
|
||
form.category_ids.push(parentId)
|
||
console.log(
|
||
'P0 AUTO-PARENT: Child category #' + id + ' selected, ' +
|
||
'auto-added parent #' + parentId
|
||
)
|
||
}
|
||
} else {
|
||
// -- DESELECT: Remove the category --
|
||
form.category_ids.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
function removeCategory(id: number): void {
|
||
const idx = form.category_ids.indexOf(id)
|
||
if (idx !== -1) {
|
||
form.category_ids.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
function toggleTreeNode(id: number): void {
|
||
const newSet = new Set(expandedNodes.value)
|
||
if (newSet.has(id)) {
|
||
newSet.delete(id)
|
||
} else {
|
||
newSet.add(id)
|
||
}
|
||
expandedNodes.value = newSet
|
||
}
|
||
|
||
// ── Vehicle Class Helpers ──────────────────────────────
|
||
function toggleVehicleClass(key: string): void {
|
||
const idx = form.supported_vehicle_classes.indexOf(key)
|
||
if (idx === -1) {
|
||
form.supported_vehicle_classes.push(key)
|
||
} else {
|
||
form.supported_vehicle_classes.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
// ── Specialization Helpers ─────────────────────────────
|
||
function addBrand(): void {
|
||
const val = brandInput.value.trim()
|
||
if (!val) return
|
||
// CRITICAL: Ensure specializations.brands exists before pushing
|
||
if (!form.specializations) {
|
||
form.specializations = { brands: [], propulsion: [] }
|
||
}
|
||
if (!form.specializations.brands) {
|
||
form.specializations.brands = []
|
||
}
|
||
form.specializations.brands.push(val)
|
||
brandInput.value = ''
|
||
}
|
||
|
||
function removeBrand(idx: number): void {
|
||
if (form.specializations?.brands) {
|
||
form.specializations.brands.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
function addPropulsion(): void {
|
||
const val = propulsionInput.value.trim()
|
||
if (!val) return
|
||
// CRITICAL: Ensure specializations.propulsion exists before pushing
|
||
if (!form.specializations) {
|
||
form.specializations = { brands: [], propulsion: [] }
|
||
}
|
||
if (!form.specializations.propulsion) {
|
||
form.specializations.propulsion = []
|
||
}
|
||
form.specializations.propulsion.push(val)
|
||
propulsionInput.value = ''
|
||
}
|
||
|
||
function removePropulsion(idx: number): void {
|
||
if (form.specializations?.propulsion) {
|
||
form.specializations.propulsion.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
// ── Opening Hours Helpers ──────────────────────────────
|
||
function toggleDay(key: string): void {
|
||
if (form.opening_hours[key]) {
|
||
delete form.opening_hours[key]
|
||
} else {
|
||
form.opening_hours[key] = { open: '08:00', close: '17:00' }
|
||
}
|
||
}
|
||
|
||
function updateDayTime(dayKey: string, field: 'open' | 'close', value: string): void {
|
||
if (!form.opening_hours[dayKey]) {
|
||
form.opening_hours[dayKey] = { open: '08:00', close: '17:00' }
|
||
}
|
||
form.opening_hours[dayKey][field] = value
|
||
}
|
||
|
||
function setAllDaysOpen(open: string, close: string): void {
|
||
for (const day of DAYS) {
|
||
form.opening_hours[day.key] = { open, close }
|
||
}
|
||
}
|
||
|
||
function copyWeekdays(): void {
|
||
// P0 FIX: Prompt user for open/close time instead of copying Monday
|
||
const openTime = prompt(t('providers.edit_copy_weekdays_open_prompt'), '08:00')
|
||
if (!openTime) return
|
||
const closeTime = prompt(t('providers.edit_copy_weekdays_close_prompt'), '17:00')
|
||
if (!closeTime) return
|
||
const weekdayKeys = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
|
||
for (const key of weekdayKeys) {
|
||
form.opening_hours[key] = { open: openTime, close: closeTime }
|
||
}
|
||
}
|
||
|
||
function clearAllHours(): void {
|
||
for (const day of DAYS) {
|
||
delete form.opening_hours[day.key]
|
||
}
|
||
}
|
||
|
||
function onAlwaysOpenChange(): void {
|
||
if (form.is_always_open) {
|
||
setAllDaysOpen('00:00', '23:59')
|
||
} else {
|
||
clearAllHours()
|
||
}
|
||
}
|
||
|
||
// ── Auth Helpers ───────────────────────────────────────
|
||
/**
|
||
* Build auth headers from the access_token cookie.
|
||
* This matches the proven pattern in index.vue.
|
||
* P0 BUGFIX: Uses useCookie('access_token') instead of localStorage.getItem('auth_token')
|
||
* which always returned null, causing 401 → auto-logout redirect.
|
||
*/
|
||
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(): Promise<void> {
|
||
loading.value = true
|
||
error.value = false
|
||
try {
|
||
const data = await $fetch<ProviderDetail>(`/api/v1/admin/providers/${providerId.value}`, {
|
||
headers: getHeaders(),
|
||
})
|
||
provider.value = data
|
||
mapProviderToForm(data)
|
||
} catch (err: any) {
|
||
console.error('Failed to fetch provider:', err)
|
||
error.value = true
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchCategoryTree(): Promise<void> {
|
||
treeLoading.value = true
|
||
try {
|
||
// P0 BUGFIX: Corrected URL from '/api/providers/categories/tree'
|
||
// to '/api/v1/providers/categories/tree' matching the backend router
|
||
// prefix (providers.router prefix="/providers" in api.py)
|
||
const data = await $fetch<CategoryTreeNode[]>('/api/v1/providers/categories/tree', {
|
||
headers: getHeaders(),
|
||
})
|
||
categoryTree.value = data
|
||
} catch (err: any) {
|
||
console.error('Failed to fetch category tree:', err)
|
||
} finally {
|
||
treeLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map API provider data to the reactive form.
|
||
*
|
||
* P0 BUGFIX: NEVER assign null to nested reactive objects.
|
||
* Always use || {} fallback to preserve the initialized structure.
|
||
*/
|
||
function mapProviderToForm(data: ProviderDetail): void {
|
||
form.name = data.name || ''
|
||
form.category = data.category || ''
|
||
form.contact_phone = data.contact_phone || ''
|
||
form.contact_email = data.contact_email || ''
|
||
form.website = data.website || ''
|
||
form.plus_code = data.plus_code || ''
|
||
form.status = data.status || 'active'
|
||
|
||
// P0 HOTFIX (2026-07-07): Deep clone arrays to break shared reference
|
||
// with provider.value. Without this, hasChanges() cannot detect mutations
|
||
// because form.category_ids and provider.value.category_ids point to the
|
||
// same array object — toggling a category mutates both simultaneously.
|
||
form.supported_vehicle_classes = [...(data.supported_vehicle_classes || [])]
|
||
form.category_ids = [...(data.category_ids || [])]
|
||
|
||
// CRITICAL: Never assign null to specializations - preserve the object structure
|
||
const spec = data.specializations || {}
|
||
// P0 HOTFIX: Deep clone sub-arrays to break shared reference
|
||
form.specializations.brands = Array.isArray(spec.brands) ? [...spec.brands] : []
|
||
form.specializations.propulsion = Array.isArray(spec.propulsion) ? [...spec.propulsion] : []
|
||
|
||
// CRITICAL: Never assign null to opening_hours - preserve the object
|
||
const hours = data.opening_hours || {}
|
||
// Clear existing hours first
|
||
for (const key of Object.keys(form.opening_hours)) {
|
||
delete form.opening_hours[key]
|
||
}
|
||
// Copy new hours
|
||
for (const [key, val] of Object.entries(hours)) {
|
||
form.opening_hours[key] = val
|
||
}
|
||
|
||
// CRITICAL: Never assign null to address - merge into existing object
|
||
const addr = data.address_detail || {}
|
||
if (typeof addr === 'object' && !Array.isArray(addr)) {
|
||
form.address.zip = addr.zip || addr.address_zip || null
|
||
form.address.city = addr.city || null
|
||
form.address.street_name = addr.street_name || addr.address_street_name || null
|
||
form.address.street_type = addr.street_type || addr.address_street_type || null
|
||
form.address.house_number = addr.house_number || addr.address_house_number || null
|
||
form.address.stairwell = addr.stairwell || null
|
||
form.address.floor = addr.floor || null
|
||
form.address.door = addr.door || null
|
||
form.address.parcel_id = addr.parcel_id || null
|
||
form.address.full_address_text = addr.full_address_text || null
|
||
form.address.latitude = addr.latitude ?? null
|
||
form.address.longitude = addr.longitude ?? null
|
||
}
|
||
}
|
||
|
||
async function saveForm(): Promise<void> {
|
||
saveLoading.value = true
|
||
try {
|
||
// P0 FIX: Sanitize opening_hours - only include days with both open AND close values
|
||
const sanitizedHours: Record<string, any> = {}
|
||
for (const day of DAYS) {
|
||
const slot = form.opening_hours[day.key]
|
||
if (slot && slot.open && slot.close) {
|
||
sanitizedHours[day.key] = { open: slot.open, close: slot.close }
|
||
}
|
||
// Closed days are omitted entirely (not sent as { open: "", close: "" })
|
||
}
|
||
|
||
const payload: Record<string, any> = {
|
||
name: form.name,
|
||
category: form.category || null,
|
||
contact_phone: form.contact_phone || null,
|
||
contact_email: form.contact_email || null,
|
||
website: form.website || null,
|
||
plus_code: form.plus_code || null,
|
||
supported_vehicle_classes: form.supported_vehicle_classes,
|
||
category_ids: form.category_ids,
|
||
specializations: form.specializations,
|
||
opening_hours: sanitizedHours,
|
||
address_detail: {
|
||
zip: form.address.zip,
|
||
city: form.address.city,
|
||
street_name: form.address.street_name,
|
||
street_type: form.address.street_type,
|
||
house_number: form.address.house_number,
|
||
stairwell: form.address.stairwell,
|
||
floor: form.address.floor,
|
||
door: form.address.door,
|
||
parcel_id: form.address.parcel_id,
|
||
full_address_text: form.address.full_address_text,
|
||
latitude: form.address.latitude,
|
||
longitude: form.address.longitude,
|
||
},
|
||
}
|
||
|
||
const updated = await $fetch(`/api/v1/admin/providers/${providerId.value}`, {
|
||
method: 'PATCH',
|
||
headers: getHeaders(),
|
||
body: payload,
|
||
})
|
||
|
||
provider.value = updated as ProviderDetail
|
||
mapProviderToForm(updated as ProviderDetail)
|
||
|
||
// P0 FIX: Promotion Redirect - check if provider ID changed (promotion from staging)
|
||
const updatedId = (updated as any).id
|
||
if (updatedId && Number(updatedId) !== providerId.value) {
|
||
showToast(t('providers.edit_toast_promoted'), 'success')
|
||
// Redirect to the new (promoted) provider's edit page
|
||
setTimeout(() => {
|
||
router.push(`/providers/${updatedId}/edit`)
|
||
}, 1500)
|
||
} else {
|
||
showToast(t('providers.edit_toast_saved'), 'success')
|
||
// Navigate back after short delay
|
||
setTimeout(() => {
|
||
goBack()
|
||
}, 1500)
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to save provider:', err)
|
||
showToast(t('providers.edit_toast_error', { message: err.message }), 'error')
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ── Navigation ─────────────────────────────────────────
|
||
function goBack(): void {
|
||
router.push(`/providers/${providerId.value}`)
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────
|
||
function showToast(message: string, type: 'success' | 'error'): void {
|
||
toast.value = { message, type }
|
||
setTimeout(() => {
|
||
toast.value = null
|
||
}, 4000)
|
||
}
|
||
|
||
// ── Lifecycle ──────────────────────────────────────────
|
||
onMounted(() => {
|
||
fetchProvider()
|
||
fetchCategoryTree()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Custom scrollbar for category tree */
|
||
.overflow-y-auto::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||
background: rgba(100, 116, 139, 0.3);
|
||
border-radius: 2px;
|
||
}
|
||
</style>
|