846 lines
37 KiB
Vue
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"
|
|
>×</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> |