1138 lines
48 KiB
Vue
1138 lines
48 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>
|
||
Vissza a szolgáltatóhoz
|
||
</button>
|
||
<h1 class="text-2xl font-bold text-white">Szolgáltató szerkesztése</h1>
|
||
<p class="text-slate-400 mt-1">Szolgáltató #{{ $route.params.id }}</p>
|
||
</div>
|
||
|
||
<!-- Loading State -->
|
||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||
<div class="text-slate-400 flex items-center gap-3">
|
||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
<span>Szolgáltató betöltése...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
||
<p class="text-rose-400">Hiba történt a szolgáltató betöltése közben.</p>
|
||
<button @click="fetchProvider" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">Újrapróbálkozás</button>
|
||
</div>
|
||
|
||
<!-- Not Found -->
|
||
<div v-else-if="!provider" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||
<p class="text-slate-400 text-center py-12">A szolgáltató nem található.</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- Tab Navigation -->
|
||
<div class="border-b border-slate-700 mb-6">
|
||
<nav class="flex gap-6 -mb-px">
|
||
<button
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
@click="activeTab = tab.key"
|
||
class="pb-3 text-sm font-medium border-b-2 transition flex items-center gap-2"
|
||
:class="activeTab === tab.key ? 'text-indigo-400 border-indigo-400' : 'text-slate-400 border-transparent hover:text-slate-300'"
|
||
>
|
||
<component :is="tab.icon" class="w-4 h-4" />
|
||
{{ tab.label }}
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════
|
||
TAB 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">Alapadatok</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">Név *</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">Kategória</label>
|
||
<input
|
||
v-model="form.category"
|
||
placeholder="pl. Autószerviz, Gumiszerviz"
|
||
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">Forrás</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">Elérhetőség</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">Telefon</label>
|
||
<input
|
||
v-model="form.contact_phone"
|
||
placeholder="+36 30 123 4567"
|
||
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">Email</label>
|
||
<input
|
||
v-model="form.contact_email"
|
||
type="email"
|
||
placeholder="info@example.com"
|
||
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">Weboldal</label>
|
||
<input
|
||
v-model="form.website"
|
||
placeholder="https://..."
|
||
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">3D Képesség Mátrix</h2>
|
||
<p class="text-sm text-slate-400 mb-6">
|
||
Állítsd be, hogy a szolgáltató milyen járműosztályokhoz, szolgáltatási kategóriákhoz
|
||
és specializációkhoz tartozik.
|
||
</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>
|
||
Járműosztályok (Vehicle Classes)
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">Mely járműtípusokat szervizeli a szolgáltató?</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>
|
||
Szolgáltatási Kategóriák (Expertise Tags)
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">Válaszd ki a szolgáltatóhoz tartozó kategóriákat a hierarchikus fából.</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>Kategóriák betöltése...</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">{{ node.name_hu || node.name_en || node.key }}</span>
|
||
<span class="text-xs text-slate-500">(Szint {{ 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>
|
||
Specializációk
|
||
</h3>
|
||
<p class="text-xs text-slate-500 mb-3">Adj meg speciális márkákat, hajtáslánc típusokat vagy egyéb címkéket.</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">Márkák (Brands)</label>
|
||
<div class="flex flex-wrap gap-2 mb-2">
|
||
<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="pl. Volvo, Scania"
|
||
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"
|
||
>
|
||
Hozzáad
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Propulsion -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-300 mb-1">Hajtáslánc (Propulsion)</label>
|
||
<div class="flex flex-wrap gap-2 mb-2">
|
||
<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-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="propulsionInput"
|
||
@keydown.enter.prevent="addPropulsion"
|
||
placeholder="pl. EV, Hybrid, Diesel"
|
||
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"
|
||
>
|
||
Hozzáad
|
||
</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">
|
||
<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">Cím adatok</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">Teljes cím</label>
|
||
<input
|
||
v-model="form.address"
|
||
placeholder="Teljes cím szövege"
|
||
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">Város</label>
|
||
<input
|
||
v-model="form.city"
|
||
placeholder="Budapest"
|
||
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">Irányítószám</label>
|
||
<input
|
||
v-model="form.address_zip"
|
||
placeholder="1011"
|
||
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">Utca</label>
|
||
<input
|
||
v-model="form.address_street_name"
|
||
placeholder="Egressy"
|
||
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">Házszám</label>
|
||
<input
|
||
v-model="form.address_house_number"
|
||
placeholder="4/b"
|
||
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">Plus Code</label>
|
||
<input
|
||
v-model="form.plus_code"
|
||
placeholder="8FVC9X8V+XV"
|
||
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">Nyitvatartás</h2>
|
||
<p class="text-sm text-slate-400 mb-6">
|
||
Állítsd be a szolgáltató nyitvatartási idejét napokra bontva.
|
||
</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">Folyamatos (0-24) nyitvatartás</span>
|
||
<p class="text-xs text-slate-400 mt-0.5">A szolgáltató a hét minden napján 0-24 óráig nyitva van</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"
|
||
>
|
||
24/7 Folyamatos nyitvatartás
|
||
</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"
|
||
>
|
||
Hétköznapok másolása (Hétfő → K-P)
|
||
</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"
|
||
>
|
||
Összes törlése
|
||
</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">Zárva</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">● Módosítások el nem mentve</span>
|
||
<span v-else class="text-slate-500">Nincs változás</span>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button
|
||
@click="goBack"
|
||
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
|
||
>
|
||
Mégse
|
||
</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>
|
||
Mentés
|
||
</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>
|
||
</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 } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
// ── Types ──────────────────────────────────────────────
|
||
interface CategoryTreeNode {
|
||
id: number
|
||
key: string
|
||
name_hu?: string | null
|
||
name_en?: 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
|
||
created_at?: string | null
|
||
}
|
||
|
||
// ── Constants ──────────────────────────────────────────
|
||
/**
|
||
* The 8 system vehicle classes.
|
||
* These match the backend VehicleClass enum values.
|
||
*/
|
||
const VEHICLE_CLASSES = [
|
||
{ key: 'car', label: 'Személyautó' },
|
||
{ key: 'motorcycle', label: 'Motorkerékpár' },
|
||
{ key: 'truck', label: 'Teherautó' },
|
||
{ key: 'hgv', label: 'Nehézgépjármű (HGV)' },
|
||
{ key: 'light_commercial', label: 'Kishaszonjármű' },
|
||
{ key: 'personal', label: 'Személyes' },
|
||
{ key: 'boat', label: 'Csónak/Vízi' },
|
||
{ key: 'other', label: 'Egyéb' },
|
||
]
|
||
|
||
// ── Route ──────────────────────────────────────────────
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const providerId = computed(() => Number(route.params.id))
|
||
|
||
// ── 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)
|
||
|
||
// Tab state
|
||
const activeTab = ref<'basic' | 'matrix' | 'location' | 'hours'>('basic')
|
||
const tabs = [
|
||
{ key: 'basic', label: 'Alapadatok & Kapcsolat', icon: 'svg' },
|
||
{ key: 'matrix', label: 'Szolgáltatások & Járművek', icon: 'svg' },
|
||
{ key: 'location', label: 'Helyszín & Lokáció', icon: 'svg' },
|
||
{ key: 'hours', label: 'Nyitvatartás', 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('')
|
||
|
||
/**
|
||
* The reactive form model.
|
||
* Initialized with defaults, then populated from provider data on load.
|
||
*
|
||
* Mapping from backend ProviderUpdateInput schema:
|
||
* - name, city, address_zip, address_street_name, address_street_type,
|
||
* address_house_number, plus_code, contact_phone, contact_email,
|
||
* website, category, category_ids, supported_vehicle_classes, specializations
|
||
*/
|
||
const form = reactive({
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
address_zip: '',
|
||
address_street_name: '',
|
||
address_street_type: '',
|
||
address_house_number: '',
|
||
plus_code: '',
|
||
contact_phone: '',
|
||
contact_email: '',
|
||
website: '',
|
||
category: '',
|
||
category_ids: [] as number[],
|
||
supported_vehicle_classes: [] as string[],
|
||
specializations: {
|
||
brands: [] as string[],
|
||
propulsion: [] as string[],
|
||
} as Record<string, string[]>,
|
||
opening_hours: {} as Record<string, { open: string; close: string } | null>,
|
||
is_always_open: false,
|
||
})
|
||
|
||
// ── Computed ───────────────────────────────────────────
|
||
|
||
/**
|
||
* Deep comparison between original provider data and current form state.
|
||
* Used to enable/disable the save button and show dirty state indicator.
|
||
*
|
||
* CRITICAL BUGFIX (2026-07-01):
|
||
* - Normalize specializations before comparison to avoid {} vs {brands:[],propulsion:[]} mismatch
|
||
* - Use JSON.stringify with sorted arrays for reliable array comparison
|
||
* - All scalar fields compared with || '' fallback for null-safety
|
||
*/
|
||
const hasChanges = computed(() => {
|
||
if (!provider.value) return false
|
||
const p = provider.value
|
||
|
||
// Scalar field comparisons
|
||
if (form.name !== (p.name || '')) return true
|
||
if (form.address !== (p.address || '')) return true
|
||
if (form.city !== (p.city || '')) return true
|
||
if (form.address_zip !== (p.address_zip || '')) return true
|
||
if (form.address_street_name !== (p.address_street_name || '')) return true
|
||
if (form.address_street_type !== (p.address_street_type || '')) return true
|
||
if (form.address_house_number !== (p.address_house_number || '')) return true
|
||
if (form.plus_code !== (p.plus_code || '')) 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.category !== (p.category || '')) return true
|
||
|
||
// Array comparisons with deep equality (JSON.stringify + sort for order-independence)
|
||
const sortedFormCategories = [...form.category_ids].sort()
|
||
const sortedOrigCategories = [...(p.category_ids || [])].sort()
|
||
if (JSON.stringify(sortedFormCategories) !== JSON.stringify(sortedOrigCategories)) return true
|
||
|
||
const sortedFormClasses = [...form.supported_vehicle_classes].sort()
|
||
const sortedOrigClasses = [...(p.supported_vehicle_classes || [])].sort()
|
||
if (JSON.stringify(sortedFormClasses) !== JSON.stringify(sortedOrigClasses)) return true
|
||
|
||
// Specializations comparison: normalize both sides to the same shape
|
||
// to avoid false-positive when API returns {} vs form default {brands:[],propulsion:[]}
|
||
const normalizeSpec = (spec: any) => ({
|
||
brands: Array.isArray(spec?.brands) ? [...spec.brands].sort() : [],
|
||
propulsion: Array.isArray(spec?.propulsion) ? [...spec.propulsion].sort() : [],
|
||
})
|
||
if (JSON.stringify(normalizeSpec(form.specializations)) !== JSON.stringify(normalizeSpec(p.specializations))) return true
|
||
|
||
// Opening hours comparison: normalize both sides to the same shape
|
||
// Supports both flat format {monday: {...}} and nested {always_open: true, days: {monday: {...}}}
|
||
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||
const normalizeHours = (hours: any): Record<string, any> => {
|
||
const result: Record<string, any> = {}
|
||
// Detect nested format with always_open metadata
|
||
const daysSource = hours?.days || hours
|
||
for (const day of DAYS) {
|
||
const slot = daysSource?.[day]
|
||
if (slot && typeof slot === 'object' && slot.open && slot.close) {
|
||
result[day] = { open: slot.open, close: slot.close }
|
||
} else {
|
||
result[day] = null
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
if (JSON.stringify(normalizeHours(form.opening_hours)) !== JSON.stringify(normalizeHours(p.opening_hours))) return true
|
||
|
||
// is_always_open comparison
|
||
if (form.is_always_open !== (p.opening_hours?.always_open === true)) return true
|
||
|
||
return false
|
||
})
|
||
|
||
// ── Auth helpers ───────────────────────────────────────
|
||
function getHeaders(): Record<string, string> {
|
||
const token = useCookie('access_token').value
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||
return headers
|
||
}
|
||
|
||
// ── API: Fetch provider ────────────────────────────────
|
||
async function fetchProvider() {
|
||
loading.value = true
|
||
error.value = false
|
||
try {
|
||
const res = await $fetch(`/api/v1/admin/providers/${providerId.value}`, {
|
||
headers: getHeaders(),
|
||
})
|
||
provider.value = res as ProviderDetail
|
||
mapProviderToForm(res as ProviderDetail)
|
||
} catch (err: any) {
|
||
console.error('Failed to fetch provider:', err)
|
||
error.value = true
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Maps the provider detail response to the reactive form.
|
||
* Handles null/undefined values and ensures arrays/dicts have defaults.
|
||
*/
|
||
function mapProviderToForm(p: ProviderDetail) {
|
||
form.name = p.name || ''
|
||
form.address = p.address || ''
|
||
form.city = p.city || ''
|
||
form.address_zip = p.address_zip || ''
|
||
form.address_street_name = p.address_street_name || ''
|
||
form.address_street_type = p.address_street_type || ''
|
||
form.address_house_number = p.address_house_number || ''
|
||
form.plus_code = p.plus_code || ''
|
||
form.contact_phone = p.contact_phone || ''
|
||
form.contact_email = p.contact_email || ''
|
||
form.website = p.website || ''
|
||
form.category = p.category || ''
|
||
form.category_ids = p.category_ids || []
|
||
form.supported_vehicle_classes = p.supported_vehicle_classes || []
|
||
|
||
// Parse specializations - handle both object and string formats
|
||
const spec = p.specializations || {}
|
||
form.specializations = {
|
||
brands: Array.isArray(spec.brands) ? spec.brands : [],
|
||
propulsion: Array.isArray(spec.propulsion) ? spec.propulsion : [],
|
||
}
|
||
|
||
// Parse opening_hours - support both flat format {monday: {...}, ...}
|
||
// and the new nested format {always_open: true, days: {monday: {...}, ...}}
|
||
const rawHours = p.opening_hours || {}
|
||
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||
|
||
// Detect if the backend stored the nested format with always_open metadata
|
||
const isAlwaysOpen = rawHours.always_open === true
|
||
const daysSource = rawHours.days || rawHours
|
||
|
||
form.is_always_open = isAlwaysOpen
|
||
|
||
const hours: Record<string, { open: string; close: string } | null> = {}
|
||
for (const day of DAYS) {
|
||
const slot = daysSource[day]
|
||
if (slot && typeof slot === 'object' && slot.open && slot.close) {
|
||
hours[day] = { open: slot.open, close: slot.close }
|
||
} else {
|
||
hours[day] = null
|
||
}
|
||
}
|
||
form.opening_hours = hours
|
||
}
|
||
|
||
// ── API: Fetch category tree ───────────────────────────
|
||
async function fetchCategoryTree() {
|
||
treeLoading.value = true
|
||
try {
|
||
const res = await $fetch('/api/v1/providers/categories/tree', {
|
||
headers: getHeaders(),
|
||
})
|
||
categoryTree.value = (res as CategoryTreeNode[]) || []
|
||
} catch (err: any) {
|
||
console.error('Failed to fetch category tree:', err)
|
||
} finally {
|
||
treeLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ── Category tree helpers ──────────────────────────────
|
||
|
||
/**
|
||
* Recursively finds a category node by ID in the tree.
|
||
*/
|
||
function findCategoryById(id: number, nodes: CategoryTreeNode[]): CategoryTreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return node
|
||
if (node.children?.length) {
|
||
const found = findCategoryById(id, node.children)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Gets the display name for a category ID (for the selected chips).
|
||
*/
|
||
function getCategoryName(id: number): string {
|
||
const node = findCategoryById(id, categoryTree.value)
|
||
if (!node) return `#${id}`
|
||
return node.name_hu || node.name_en || node.key
|
||
}
|
||
|
||
// ── Dimension A: Vehicle Class toggles ─────────────────
|
||
|
||
/**
|
||
* Toggles a vehicle class in the form.supported_vehicle_classes array.
|
||
* If already selected, removes it; otherwise adds it.
|
||
*/
|
||
function toggleVehicleClass(key: string) {
|
||
const idx = form.supported_vehicle_classes.indexOf(key)
|
||
if (idx >= 0) {
|
||
form.supported_vehicle_classes.splice(idx, 1)
|
||
} else {
|
||
form.supported_vehicle_classes.push(key)
|
||
}
|
||
}
|
||
|
||
// ── Dimension B: Category toggles ──────────────────────
|
||
|
||
/**
|
||
* Toggles a category ID in the form.category_ids array.
|
||
* If already selected, removes it; otherwise adds it.
|
||
*/
|
||
function toggleCategory(id: number) {
|
||
const idx = form.category_ids.indexOf(id)
|
||
if (idx >= 0) {
|
||
form.category_ids.splice(idx, 1)
|
||
} else {
|
||
form.category_ids.push(id)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggles the expanded/collapsed state of a tree node.
|
||
*/
|
||
function toggleTreeNode(id: number) {
|
||
const newSet = new Set(expandedNodes.value)
|
||
if (newSet.has(id)) {
|
||
newSet.delete(id)
|
||
} else {
|
||
newSet.add(id)
|
||
}
|
||
expandedNodes.value = newSet
|
||
}
|
||
|
||
/**
|
||
* Checks if a category ID is currently selected.
|
||
*/
|
||
function isCategorySelected(id: number): boolean {
|
||
return form.category_ids.includes(id)
|
||
}
|
||
|
||
/**
|
||
* Removes a category ID from the selection.
|
||
*/
|
||
function removeCategory(id: number) {
|
||
const idx = form.category_ids.indexOf(id)
|
||
if (idx >= 0) {
|
||
form.category_ids.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
// ── Dimension C: Specialization tag management ─────────
|
||
|
||
/**
|
||
* Adds the current brandInput value to the brands array.
|
||
*/
|
||
function addBrand() {
|
||
const val = brandInput.value.trim()
|
||
if (val && !form.specializations.brands.includes(val)) {
|
||
form.specializations.brands.push(val)
|
||
}
|
||
brandInput.value = ''
|
||
}
|
||
|
||
/**
|
||
* Removes a brand at the given index.
|
||
*/
|
||
function removeBrand(idx: number) {
|
||
form.specializations.brands.splice(idx, 1)
|
||
}
|
||
|
||
/**
|
||
* Adds the current propulsionInput value to the propulsion array.
|
||
*/
|
||
function addPropulsion() {
|
||
const val = propulsionInput.value.trim()
|
||
if (val && !form.specializations.propulsion.includes(val)) {
|
||
form.specializations.propulsion.push(val)
|
||
}
|
||
propulsionInput.value = ''
|
||
}
|
||
|
||
/**
|
||
* Removes a propulsion entry at the given index.
|
||
*/
|
||
function removePropulsion(idx: number) {
|
||
form.specializations.propulsion.splice(idx, 1)
|
||
}
|
||
|
||
// ── DAYS constant ──────────────────────────────────────
|
||
const DAYS = [
|
||
{ key: 'monday', label: 'Hétfő' },
|
||
{ key: 'tuesday', label: 'Kedd' },
|
||
{ key: 'wednesday', label: 'Szerda' },
|
||
{ key: 'thursday', label: 'Csütörtök' },
|
||
{ key: 'friday', label: 'Péntek' },
|
||
{ key: 'saturday', label: 'Szombat' },
|
||
{ key: 'sunday', label: 'Vasárnap' },
|
||
]
|
||
|
||
// ── Opening Hours helpers ──────────────────────────────
|
||
|
||
/**
|
||
* Called when the "Folyamatos (0-24) nyitvatartás" checkbox is toggled.
|
||
* When checked: sets all 7 days to 00:00-23:59.
|
||
* When unchecked: clears all hours so the user can set custom times.
|
||
*/
|
||
function onAlwaysOpenChange() {
|
||
if (form.is_always_open) {
|
||
setAllDaysOpen('00:00', '23:59')
|
||
} else {
|
||
clearAllHours()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggles a day on/off in the opening_hours form.
|
||
* When enabling, defaults to 08:00-17:00.
|
||
*/
|
||
function toggleDay(dayKey: string) {
|
||
if (form.opening_hours[dayKey]) {
|
||
form.opening_hours[dayKey] = null
|
||
} else {
|
||
form.opening_hours[dayKey] = { open: '08:00', close: '17:00' }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the open or close time for a given day.
|
||
*/
|
||
function updateDayTime(dayKey: string, field: 'open' | 'close', value: string) {
|
||
const slot = form.opening_hours[dayKey]
|
||
if (slot) {
|
||
slot[field] = value
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets all 7 days to the same open/close times (e.g. 00:00-23:59 for 24/7).
|
||
*/
|
||
function setAllDaysOpen(open: string, close: string) {
|
||
for (const day of DAYS) {
|
||
form.opening_hours[day.key] = { open, close }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Copies Monday's opening hours to Tuesday through Friday.
|
||
* If Monday is closed, does nothing.
|
||
*/
|
||
function copyWeekdays() {
|
||
const monday = form.opening_hours['monday']
|
||
if (!monday) return
|
||
const weekdayKeys = ['tuesday', 'wednesday', 'thursday', 'friday']
|
||
for (const key of weekdayKeys) {
|
||
form.opening_hours[key] = { open: monday.open, close: monday.close }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clears all opening hours (sets all days to null/closed).
|
||
*/
|
||
function clearAllHours() {
|
||
for (const day of DAYS) {
|
||
form.opening_hours[day.key] = null
|
||
}
|
||
}
|
||
|
||
// ── Save ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* Saves the form data via PATCH /admin/providers/{id}.
|
||
* Builds the payload matching the ProviderUpdateInput schema.
|
||
* On success: navigates back to the provider detail page.
|
||
* On error: shows error toast.
|
||
*/
|
||
async function saveForm() {
|
||
if (saveLoading.value) return
|
||
saveLoading.value = true
|
||
|
||
// CRITICAL BUGFIX (2026-07-01): address is NOT NULL in the DB schema,
|
||
// so we must never send null for it. Fall back to name if empty.
|
||
const safeAddress = form.address?.trim() || form.name
|
||
|
||
const payload: Record<string, any> = {
|
||
name: form.name,
|
||
address: safeAddress,
|
||
category: form.category || null,
|
||
contact_phone: form.contact_phone || null,
|
||
contact_email: form.contact_email || null,
|
||
website: form.website || null,
|
||
city: form.city || null,
|
||
address_zip: form.address_zip || null,
|
||
address_street_name: form.address_street_name || null,
|
||
address_street_type: form.address_street_type || null,
|
||
address_house_number: form.address_house_number || null,
|
||
plus_code: form.plus_code || null,
|
||
category_ids: form.category_ids,
|
||
supported_vehicle_classes: form.supported_vehicle_classes,
|
||
specializations: form.specializations,
|
||
opening_hours: form.opening_hours,
|
||
is_always_open: form.is_always_open,
|
||
}
|
||
|
||
try {
|
||
await $fetch(`/api/v1/admin/providers/${providerId.value}`, {
|
||
method: 'PATCH',
|
||
headers: getHeaders(),
|
||
body: payload,
|
||
})
|
||
showToast('success', 'Szolgáltató sikeresen frissítve!')
|
||
setTimeout(() => {
|
||
router.push(`/providers/${providerId.value}`)
|
||
}, 1200)
|
||
} catch (err: any) {
|
||
console.error('Failed to save provider:', err)
|
||
const msg = err?.data?.detail || err?.message || 'Ismeretlen hiba történt mentés közben.'
|
||
showToast('error', `Hiba: ${msg}`)
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ── Navigation ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Navigates back to the provider detail page.
|
||
*/
|
||
function goBack() {
|
||
router.push(`/providers/${providerId.value}`)
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Shows a temporary toast notification that auto-hides after 3 seconds.
|
||
*/
|
||
function showToast(type: 'success' | 'error', message: string) {
|
||
toast.value = { type, message }
|
||
setTimeout(() => {
|
||
toast.value = null
|
||
}, 3000)
|
||
}
|
||
|
||
// ── Lifecycle ──────────────────────────────────────────
|
||
|
||
onMounted(async () => {
|
||
await fetchProvider()
|
||
await fetchCategoryTree()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Custom scrollbar for the category tree */
|
||
.overflow-y-auto::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||
background-color: rgba(100, 116, 139, 0.5);
|
||
border-radius: 3px;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||
background-color: rgba(100, 116, 139, 0.7);
|
||
}
|
||
</style> |