Files
service-finder/frontend_app/src/components/provider/ProviderEditModal.vue
2026-06-24 11:29:45 +00:00

846 lines
37 KiB
Vue

<template>
<Teleport to="body">
<div
v-if="isOpen && provider"
class="fixed inset-0 z-[10002] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
style="max-height: 85vh;"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.editTitle') }}</h3>
<p class="text-xs text-slate-500">{{ provider.name }}</p>
</div>
</div>
<button
@click="emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('serviceFinder.close')"
>
<svg class="h-5 w-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>
<!-- Tab Bar -->
<div class="flex border-b border-slate-200 px-6 shrink-0">
<button
@click="activeTab = 'basic'"
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
:class="activeTab === 'basic'
? 'border-sf-accent text-sf-accent'
: 'border-transparent text-slate-500 hover:text-slate-700'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ t('provider.tabBasic') }}
</button>
<button
@click="activeTab = 'services'"
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
:class="activeTab === 'services'
? 'border-sf-accent text-sf-accent'
: 'border-transparent text-slate-500 hover:text-slate-700'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
{{ t('provider.tabServices') }}
</button>
</div>
<!-- Scrollable Body -->
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
<!-- -->
<!-- TAB 1: Alapadatok (Basic Data) -->
<!-- -->
<template v-if="activeTab === 'basic'">
<!-- Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.namePlaceholder')"
/>
</div>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<div class="border-t border-slate-100 pt-4">
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
<!-- Street Name -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('common.streetName') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span>
</label>
<input
v-model="form.address_street_name"
type="text"
maxlength="150"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.streetNamePlaceholder')"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetTypeLabel') }}
</label>
<select
v-model="form.address_street_type"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
<option value="utca">utca</option>
<option value="út">út</option>
<option value="tér">tér</option>
<option value="körút">körút</option>
<option value="sugárút">sugárút</option>
<option value="sétány">sétány</option>
<option value="köz">köz</option>
<option value="sor">sor</option>
<option value="liget">liget</option>
<option value="park">park</option>
<option value="rakpart">rakpart</option>
<option value="dűlő">dűlő</option>
<option value="hegy">hegy</option>
<option value="lakópark">lakópark</option>
<option value="üdülőtelep">üdülőtelep</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.houseNumberLabel') }}
</label>
<input
v-model="form.address_house_number"
type="text"
maxlength="20"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.houseNumberPlaceholder')"
/>
</div>
</div>
<!-- City + ZIP row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.zipLabel') }}
</label>
<input
v-model="form.zip"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.zipPlaceholder')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.cityLabel') }}
</label>
<input
v-model="form.city"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.cityPlaceholder')"
/>
</div>
</div>
<!-- Plus Code -->
<div class="mt-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.plusCodeLabel') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.plusCodeOptional') }})</span>
</label>
<input
v-model="form.plus_code"
type="text"
maxlength="20"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.plusCodePlaceholder')"
/>
</div>
</div>
<!-- Elérhetőségek (Contact) -->
<div class="border-t border-slate-100 pt-4">
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.contactSection') }}</p>
<!-- Phone -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('common.phone') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span>
</label>
<input
v-model="form.phone"
type="tel"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.phonePlaceholder')"
/>
</div>
<!-- Email -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('common.email') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span>
</label>
<input
v-model="form.email"
type="email"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.emailPlaceholder')"
/>
</div>
<!-- Website -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.websiteLabel') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.websiteOptional') }})</span>
</label>
<input
v-model="form.website"
type="url"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.websitePlaceholder')"
/>
</div>
</div>
</template>
<!-- -->
<!-- TAB 2: Szolgáltatások (Services) -->
<!-- -->
<template v-if="activeTab === 'services'">
<!-- -->
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
<!-- -->
<div>
<div class="flex items-center gap-2 mb-3">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
</div>
<!-- Loading state -->
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ t('common.loading') }}
</div>
<!-- Level 0: Járműtípus (always visible) -->
<div v-if="level0Categories.length > 0" class="mb-3">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="cat in level0Categories"
:key="cat.id"
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
>
<input
type="checkbox"
:checked="selectedCategoryIds.includes(cat.id)"
@change="toggleCategory(cat.id)"
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
/>
{{ cat.name_hu || cat.key }}
</label>
</div>
</div>
<!-- Level 1: Iparág/Főcsoport (children of CHECKED Level 0) -->
<div v-if="vehicleLevel1Categories.length > 0" class="mb-3 ml-4 pl-3 border-l-2 border-sf-accent/20">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level1Label') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="l1 in vehicleLevel1Categories"
:key="l1.id"
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(l1.id) }"
>
<input
type="checkbox"
:checked="selectedCategoryIds.includes(l1.id)"
@change="toggleCategory(l1.id)"
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
/>
{{ l1.name_hu || l1.key }}
</label>
</div>
</div>
<!-- Universal Level 1: Iparágak és Univerzális Szolgáltatások (independent of Level 0) -->
<div v-if="universalLevel1Categories.length > 0" class="mb-3 mt-4 pt-3 border-t-2 border-dashed border-amber-200">
<div class="flex items-center gap-2 mb-3">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-50 text-amber-500">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<span class="text-sm font-bold text-slate-700">{{ t('categories.universal_title') }}</span>
</div>
<p class="text-xs text-slate-400 mb-2">{{ t('categories.universal_hint') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="l1 in universalLevel1Categories"
:key="l1.id"
class="inline-flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-amber-400 hover:bg-amber-50 cursor-pointer"
:class="{ 'border-amber-500 bg-amber-50 text-amber-700 font-semibold': selectedCategoryIds.includes(l1.id) }"
>
<input
type="checkbox"
:checked="selectedCategoryIds.includes(l1.id)"
@change="toggleCategory(l1.id)"
class="h-3.5 w-3.5 rounded border-amber-300 text-amber-500 focus:ring-amber-300"
/>
{{ l1.name_hu || l1.key }}
</label>
</div>
</div>
</div>
<!-- -->
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
<!-- -->
<div class="border-t border-slate-100 pt-4">
<div class="flex items-center gap-2 mb-3">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
</div>
<!-- Autocomplete input -->
<div class="relative">
<input
v-model="autocompleteQuery"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('categories.search_placeholder')"
@input="onAutocompleteInput"
@keydown.enter.prevent="onAutocompleteEnter"
@keydown.backspace="onAutocompleteBackspace"
@blur="onAutocompleteBlur"
/>
<!-- Autocomplete dropdown -->
<div
v-if="autocompleteQuery.length >= 2"
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
>
<!-- Results list -->
<button
v-for="item in autocompleteResults"
:key="item.id"
@mousedown.prevent="selectAutocompleteItem(item)"
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
>
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
<span>{{ item.name_hu || item.key }}</span>
</button>
<!-- No results message -->
<div
v-if="autocompleteResults.length === 0"
class="px-4 py-3 text-sm text-slate-400 text-center"
>
{{ t('categories.no_results_create') }}
</div>
</div>
</div>
<!-- Selected tags (chips) -->
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in selectedTags"
:key="'tag-' + idx"
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
>
{{ tag.name_hu || tag.key }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</div>
</div>
<!-- Gamification Toast -->
<div
v-if="gamificationToast"
class="flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium shadow-sm transition-all duration-300"
:class="gamificationToast.type === 'success'
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-amber-50 text-amber-700 border border-amber-200'"
>
<svg v-if="gamificationToast.type === 'success'" class="h-5 w-5 shrink-0 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="h-5 w-5 shrink-0 text-amber-500" 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>
<span>{{ gamificationToast.message }}</span>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
</template>
</div>
<!-- Sticky Footer (Save / Cancel) -->
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
<div class="flex gap-3">
<button
@click="emit('close')"
class="flex-1 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:bg-slate-50 cursor-pointer"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleSave"
:disabled="isSaving || !form.name.trim()"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 cursor-pointer"
>
<svg v-if="isSaving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else class="h-4 w-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>
{{ isSaving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
/**
* ProviderEditModal.vue — Teljes Szerkesztő Ablak szolgáltató adatokhoz.
*
* P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
* - A régi single 'street' mező helyett 3 külön mező:
* address_street_name, address_street_type, address_house_number
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
*
* UX OVERHAUL (2026-06-17):
* - Tab 1: Alapadatok (Név, Cím 3 bontásban, Elérhetőségek)
* - Tab 2: Szolgáltatások (Kategória fa + Autocomplete)
* - Görgethető body (overflow-y-auto, max-height: 85vh)
* - Sticky footer a Mentés/Mégse gombokkal
* - Okos Kategória Fa: csak a kipipált Szint 0 gyerekei jelennek meg
*
* 4-Level Category Hybrid UI (2026-06-17):
* ==========================================
* BLOCK A (Fix Struktúra): Szint 0 (Járműtípus) + Szint 1 (Iparág) checkboxok
* - Okos: Szint 1 csak akkor jelenik meg, ha a Szint 0 ki van pipálva
* BLOCK B (Okos Szövegmező): Szint 2 + Szint 3 autocomplete + chip tags
* - Ha a gépelt szó létezik → legördül és kiválasztható
* - Ha nem létezik → Enter → új chip címke (new_tags)
*
* @emits close — modal bezárása
* @emits saved — sikeres mentés után, payload: { id: number }
*
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
*/
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
const { t } = useI18n()
// ── Types ──
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
tags: string[]
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
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 AutocompleteItem {
id: number
key: string
name_hu: string | null
name_en: string | null
level: number
path: string | null
parent_id: number | null
}
interface SelectedTag {
id: number | null // null for user-created tags (not yet in DB)
key: string
name_hu: string | null
level: number
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
provider: ProviderSearchResult | null
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved', payload: { id: number }): void
}>()
// ── Tab State ──
const activeTab = ref<'basic' | 'services'>('basic')
// ── Form State ──
const isSaving = ref(false)
// ── Gamification Toast ──
const gamificationToast = ref<{ type: 'success' | 'penalty'; message: string } | null>(null)
interface FormState {
name: string
city: string
zip: string
address_street_name: string
address_street_type: string
address_house_number: string
plus_code: string
phone: string
email: string
website: string
}
const form = reactive<FormState>({
name: '',
city: '',
zip: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
plus_code: '',
phone: '',
email: '',
website: '',
})
// ── Category State (4-Level) ──
const categoryTreeLoading = ref(false)
const categoryTree = ref<CategoryTreeNode[]>([])
const selectedCategoryIds = ref<number[]>([])
const selectedTags = ref<SelectedTag[]>([])
const autocompleteQuery = ref('')
const autocompleteResults = ref<AutocompleteItem[]>([])
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
// ── Computed: Level 0 categories ──
const level0Categories = computed(() => {
return categoryTree.value.filter(c => c.level === 0)
})
// ── Computed: Vehicle-specific Level 1 categories ──
// Shows Level 1 children of CHECKED Level 0 parents only.
const vehicleLevel1Categories = computed(() => {
const checkedL0Ids = new Set(selectedCategoryIds.value)
const result: CategoryTreeNode[] = []
for (const l0 of level0Categories.value) {
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
for (const l1 of l0.children) {
// Avoid duplicates if same L1 appears under multiple L0
if (!result.some(r => r.id === l1.id)) {
result.push(l1)
}
}
}
}
return result
})
// ── Computed: Universal Level 1 categories ──
// Level 1 categories that are root nodes (parent_id IS NULL) in the tree.
// These are independent of any vehicle type and always visible.
// Examples: "Üzemanyag és Töltőállomás", "Étel és Ital", "Szállás"
const universalLevel1Categories = computed(() => {
return categoryTree.value.filter(c => c.level === 1)
})
// ── Load category tree on mount ──
onMounted(async () => {
await loadCategoryTree()
})
// ── Watch provider changes → populate form ──
watch(
() => props.provider,
(provider) => {
if (provider) {
form.name = provider.name || ''
form.city = provider.city || ''
form.zip = provider.address_zip || ''
// P1 CRITICAL ALIGN: Atomizált címmezők kitöltése
form.address_street_name = provider.address_street_name || ''
form.address_street_type = provider.address_street_type || ''
form.address_house_number = provider.address_house_number || ''
form.phone = provider.contact_phone || ''
form.email = provider.contact_email || ''
form.website = provider.website || ''
}
},
{ immediate: true }
)
// ── Load Category Tree from API ──
async function loadCategoryTree() {
categoryTreeLoading.value = true
try {
const response = await api.get('providers/categories/tree')
categoryTree.value = response.data || []
} catch (err) {
console.error('[ProviderEditModal] Failed to load category tree:', err)
} finally {
categoryTreeLoading.value = false
}
}
// ── Toggle category selection (Block A) ──
function toggleCategory(categoryId: number) {
const idx = selectedCategoryIds.value.indexOf(categoryId)
if (idx >= 0) {
selectedCategoryIds.value.splice(idx, 1)
} else {
selectedCategoryIds.value.push(categoryId)
}
}
// ── Autocomplete input handler (Block B) ──
function onAutocompleteInput() {
if (autocompleteTimer) {
clearTimeout(autocompleteTimer)
}
const query = autocompleteQuery.value.trim()
if (query.length < 2) {
autocompleteResults.value = []
return
}
// Debounce: 300ms
autocompleteTimer = setTimeout(async () => {
try {
const response = await api.get('providers/categories/autocomplete', {
params: { q: query },
})
// Filter out already selected tags
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
autocompleteResults.value = (response.data || []).filter(
(item: AutocompleteItem) => !selectedKeys.has(item.key)
)
} catch (err) {
console.error('[ProviderEditModal] Autocomplete error:', err)
autocompleteResults.value = []
}
}, 300)
}
// ── Select autocomplete item ──
function selectAutocompleteItem(item: AutocompleteItem) {
selectedTags.value.push({
id: item.id,
key: item.key,
name_hu: item.name_hu,
level: item.level,
})
autocompleteQuery.value = ''
autocompleteResults.value = []
}
// ── Enter key: create new tag if not found ──
function onAutocompleteEnter() {
const query = autocompleteQuery.value.trim()
if (!query) return
// Check if already selected
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
autocompleteQuery.value = ''
return
}
// Check if it matches any autocomplete result
const match = autocompleteResults.value.find(
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
)
if (match) {
selectAutocompleteItem(match)
return
}
// Create new tag (user-created, not yet in DB)
selectedTags.value.push({
id: null, // null = not yet in DB
key: query.toLowerCase().replace(/\s+/g, '_'),
name_hu: query,
level: 3,
})
autocompleteQuery.value = ''
autocompleteResults.value = []
}
// ── Backspace on empty input: remove last tag ──
function onAutocompleteBackspace() {
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
selectedTags.value.pop()
}
}
// ── Blur: close autocomplete dropdown ──
function onAutocompleteBlur() {
// Delay to allow click on dropdown items
setTimeout(() => {
autocompleteResults.value = []
}, 200)
}
// ── Remove tag ──
function removeTag(index: number) {
selectedTags.value.splice(index, 1)
}
// ── Save handler (async — calls backend PUT /providers/{id}) ──
async function handleSave() {
if (!form.name.trim() || !props.provider) return
isSaving.value = true
gamificationToast.value = null
try {
// Separate existing tag IDs from new tag names
const existingCategoryIds = selectedTags.value
.filter(t => t.id !== null)
.map(t => t.id as number)
const newTagNames = selectedTags.value
.filter(t => t.id === null)
.map(t => t.name_hu || t.key)
// Combine Block A + Block B category IDs
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
const body: Record<string, any> = {
name: form.name.trim(),
city: form.city.trim() || null,
address_zip: form.zip.trim() || null,
address_street_name: form.address_street_name.trim() || null,
address_street_type: form.address_street_type.trim() || null,
address_house_number: form.address_house_number.trim() || null,
plus_code: form.plus_code.trim() || null,
contact_phone: form.phone.trim() || null,
contact_email: form.email.trim() || null,
website: form.website.trim() || null,
tags: null,
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
new_tags: newTagNames.length > 0 ? newTagNames : null,
}
const response = await api.put(`providers/${props.provider.id}`, body)
const earnedPoints = response.data?.earned_points
// Show gamification toast if points were awarded
if (earnedPoints !== undefined && earnedPoints !== null) {
if (earnedPoints >= 0) {
gamificationToast.value = {
type: 'success',
message: t('gamification.points_awarded', { points: earnedPoints }),
}
} else {
gamificationToast.value = {
type: 'penalty',
message: t('gamification.penalty_applied', { points: Math.abs(earnedPoints) }),
}
}
}
// Emit success so parent can refresh search results
emit('saved', { id: props.provider.id })
} catch (err: any) {
console.error('[ProviderEditModal] Save error:', err)
// Keep modal open on error so user can retry
} finally {
isSaving.value = false
}
}
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>