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

1326 lines
59 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<!-- Back Button & Header -->
<div class="mb-8">
<button
@click="goBack"
class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ $t('providers.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>