Files
service-finder/frontend_admin/pages/providers/[id]/edit.vue
2026-07-01 02:27:38 +00:00

1138 lines
48 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>
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>