céges meghívó kezelése,

This commit is contained in:
Roo
2026-06-17 22:07:55 +00:00
parent bf3a971ff1
commit 127b130401
28 changed files with 5806 additions and 1313 deletions

View File

@@ -139,9 +139,14 @@ onMounted(async () => {
})
// ── Filter: only real companies (exclude individual/private orgs) ──
// Also exclude service_provider and service types — those belong to the
// Marketplace / Service Finder domain, not the user's own companies.
const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter(
(org) => org.org_type && org.org_type !== 'individual'
(org) => org.org_type &&
org.org_type !== 'individual' &&
org.org_type !== 'service_provider' &&
org.org_type !== 'service'
)
})

View File

@@ -17,7 +17,7 @@
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('serviceFinder.detailTitle') }}</h3>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.detailTitle') }}</h3>
</div>
</div>
<button
@@ -33,116 +33,149 @@
<!-- Body -->
<div class="p-6 space-y-5">
<!-- Provider Name -->
<!-- Provider Name + Source Badge -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-extrabold text-slate-800 truncate">{{ provider.name }}</h2>
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</div>
</div>
<!-- Categories (chips/badges) -->
<div>
<h2 class="text-2xl font-extrabold text-slate-800">{{ provider.name }}</h2>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailCategories') }}</p>
<div v-if="provider.categories && provider.categories.length > 0" class="flex flex-wrap gap-1.5">
<span
v-for="cat in provider.categories"
:key="cat.id"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="categoryBadgeClass(cat.level)"
>
{{ categoryName(cat) }}
</span>
</div>
<p v-else class="text-sm text-slate-400 italic">{{ t('provider.detailNoCategories') }}</p>
</div>
<!-- Category -->
<div v-if="provider.category" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p>
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p>
</div>
</div>
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p>
<p class="text-sm font-medium text-slate-800">
{{ formattedAddress }}
</p>
</div>
</div>
<!-- Source -->
<div class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
<p class="text-sm font-medium text-slate-800">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</p>
</div>
</div>
<!-- Specialization -->
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="(tag, idx) in provider.specialization"
:key="idx"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
</span>
<!-- Contact Grid -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
<div class="grid grid-cols-1 gap-2">
<!-- Phone -->
<a
v-if="provider.contact_phone"
:href="'tel:' + provider.contact_phone"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span>{{ provider.contact_phone }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoPhone') }}</span>
</div>
</div>
</div>
<!-- Rating -->
<div v-if="provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<div class="flex items-center gap-1 mt-0.5">
<svg
v-for="star in 5"
:key="star"
class="h-4 w-4"
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
<!-- Email -->
<a
v-if="provider.contact_email"
:href="'mailto:' + provider.contact_email"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="truncate">{{ provider.contact_email }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoEmail') }}</span>
</div>
<!-- Website -->
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<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="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="truncate">{{ provider.website }}</span>
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span class="ml-1 text-sm font-medium text-slate-700">{{ provider.rating.toFixed(1) }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<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="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="italic">{{ t('provider.detailNoWebsite') }}</span>
</div>
</div>
</div>
<!-- No rating fallback -->
<div v-if="!provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p>
<!-- Location & Plus Code -->
<div v-if="hasAddress || provider.plus_code">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
<div class="rounded-xl bg-slate-50 p-4 space-y-3">
<!-- Address -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p class="text-sm font-medium text-slate-700">{{ formattedAddress }}</p>
</div>
<!-- Plus Code -->
<div v-if="provider.plus_code" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<p class="text-sm font-medium text-slate-700 font-mono">{{ provider.plus_code }}</p>
</div>
<!-- Navigate Button (Google Maps) -->
<a
:href="googleMapsUrl"
target="_blank"
rel="noopener noreferrer"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
>
<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 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
{{ t('provider.detailNavigate') }}
</a>
</div>
</div>
@@ -169,20 +202,30 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ──
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
categories: CategoryInfo[]
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
plus_code: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
@@ -223,10 +266,32 @@ const sourceLabel = computed(() => {
}
})
/**
* Category badge color based on level.
* Level 0 = Vehicle Type (purple), Level 1 = Industry (blue),
* Level 2 = Profession (teal), Level 3 = Specific Tag (slate).
*/
function categoryBadgeClass(level: number): string {
switch (level) {
case 0: return 'bg-purple-100 text-purple-700'
case 1: return 'bg-blue-100 text-blue-700'
case 2: return 'bg-teal-100 text-teal-700'
case 3: return 'bg-slate-100 text-slate-600'
default: return 'bg-slate-100 text-slate-600'
}
}
/**
* Get the localized category name based on current locale.
*/
function categoryName(cat: CategoryInfo): string {
if (locale.value === 'hu' && cat.name_hu) return cat.name_hu
if (cat.name_en) return cat.name_en
return cat.key
}
/**
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
* A provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number
* Ha a city is meg van adva, akkor "city, street_name street_type house_number" formátumban jelenik meg.
*/
const hasAddress = computed(() => {
const p = props.provider
@@ -258,6 +323,31 @@ const formattedAddress = computed(() => {
return parts.join(', ')
})
/**
* Google Maps URL: prefer plus_code, fall back to address query.
*/
const googleMapsUrl = computed(() => {
const p = props.provider
if (!p) return 'https://maps.google.com'
if (p.plus_code) {
return `https://maps.google.com/maps?q=${encodeURIComponent(p.plus_code)}`
}
const queryParts: string[] = []
if (p.address_street_name) queryParts.push(p.address_street_name)
if (p.address_street_type) queryParts.push(p.address_street_type)
if (p.address_house_number) queryParts.push(p.address_house_number)
if (p.city) queryParts.push(p.city)
if (p.address_zip) queryParts.push(p.address_zip)
if (queryParts.length > 0) {
return `https://maps.google.com/maps?q=${encodeURIComponent(queryParts.join(' '))}`
}
return `https://maps.google.com/maps?q=${encodeURIComponent(p.name)}`
})
</script>
<style scoped>

View File

@@ -6,10 +6,11 @@
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
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">
<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">
@@ -32,178 +33,395 @@
</button>
</div>
<!-- Form Body -->
<div class="p-6 space-y-5">
<!-- 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>
<!-- 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>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<!-- Street Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetNameLabel') }}
</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">
<!-- 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.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') }}
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.address_house_number"
v-model="form.name"
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')"
:placeholder="t('provider.namePlaceholder')"
/>
</div>
</div>
<!-- City + ZIP row -->
<div class="grid grid-cols-2 gap-4">
<!-- 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('provider.streetNameLabel') }}
<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('provider.phoneLabel') }}
<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('provider.emailLabel') }}
<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>
<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 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>
<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')"
/>
<!-- -->
<!-- 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>
</div>
<!-- Phone -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.phoneLabel') }}
</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>
<!-- 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>
<!-- Email -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }}
</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>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Website -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.websiteLabel') }}
</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>
<!-- Tags (comma-separated) -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.tagsLabel') }}
</label>
<input
v-model="form.tagsInput"
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.tagsPlaceholder')"
/>
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in form.tags"
:key="idx"
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
</div>
</template>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
<!-- Action buttons -->
<!-- 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')"
@@ -241,13 +459,28 @@
* 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 } from 'vue'
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
@@ -274,6 +507,34 @@ interface ProviderSearchResult {
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
@@ -286,9 +547,15 @@ const emit = defineEmits<{
(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
@@ -296,11 +563,10 @@ interface FormState {
address_street_name: string
address_street_type: string
address_house_number: string
plus_code: string
phone: string
email: string
website: string
tagsInput: string
tags: string[]
}
const form = reactive<FormState>({
@@ -310,11 +576,55 @@ const form = reactive<FormState>({
address_street_name: '',
address_street_type: '',
address_house_number: '',
plus_code: '',
phone: '',
email: '',
website: '',
tagsInput: '',
tags: [],
})
// ── 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 ──
@@ -332,33 +642,125 @@ watch(
form.phone = provider.contact_phone || ''
form.email = provider.contact_email || ''
form.website = provider.website || ''
form.tagsInput = ''
form.tags = provider.tags && provider.tags.length > 0
? [...provider.tags]
: provider.specialization
? [...provider.specialization]
: []
}
},
{ immediate: true }
)
// ── Tag management ──
watch(
() => form.tagsInput,
(val) => {
if (val.endsWith(',') || val.endsWith(';')) {
const newTag = val.slice(0, -1).trim()
if (newTag && !form.tags.includes(newTag)) {
form.tags.push(newTag)
}
form.tagsInput = ''
}
// ── 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) {
form.tags.splice(index, 1)
selectedTags.value.splice(index, 1)
}
// ── Save handler (async — calls backend PUT /providers/{id}) ──
@@ -366,27 +768,58 @@ 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 = {
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: form.tags.length > 0 ? [...form.tags] : null,
tags: null,
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
new_tags: newTagNames.length > 0 ? newTagNames : null,
}
await api.put(`providers/${props.provider.id}`, body)
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 })
emit('close')
} catch (err: any) {
console.error('[ProviderEditModal] Save error:', err)
// Keep modal open on error so user can retry
@@ -410,4 +843,4 @@ async function handleSave() {
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Finance & Costs',
diagnostic: 'Live Diagnostics',
addVehicle: '+ Add New Vehicle',
service: 'Service',
},
header: {
welcome: 'Welcome',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Tow Truck',
filterBenzinkut: '⛽ Gas Station',
filterAutoKozmetika: '✨ Car Detailing',
// Category filter (FEATURE 2026-06-17)
categoryPlaceholder: 'Search category (e.g. AC, tire)',
categoryFilterTitle: 'Category filter',
categoryRemove: 'Remove',
categoryNoResults: 'No results',
// SOS Tab
sosTabTitle: 'SOS Rescue',
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!',
@@ -1299,21 +1305,60 @@ export default {
duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
// Edit Modal
editTitle: 'Edit Provider Data',
tabBasic: 'Basic Data',
tabServices: 'Services',
addressSection: 'Address Details',
contactSection: 'Contact Details',
streetNameLabel: 'Street Name',
streetNameOptional: 'optional',
streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type',
streetTypePlaceholder: 'Select type...',
houseNumberLabel: 'House Number',
houseNumberPlaceholder: 'e.g. 4',
phoneLabel: 'Phone',
phoneOptional: 'optional',
phonePlaceholder: 'e.g. +36 20 123 4567',
emailLabel: 'Email',
emailOptional: 'optional',
emailPlaceholder: 'e.g. info@provider.hu',
websiteLabel: 'Website',
websiteOptional: 'optional',
websitePlaceholder: 'e.g. https://provider.hu',
tagsLabel: 'Service Tags',
tagsPlaceholder: 'Add tags (comma-separated)',
level0Label: 'Vehicle Type',
level1Label: 'Industry / Main Group',
editInfo: 'Your edits will be reviewed and may earn gamification points!',
// Plus Code
plusCodeLabel: 'Plus Code',
plusCodeOptional: 'optional',
plusCodePlaceholder: 'e.g. 8FVC9X8V+XV',
// Detail Modal
detailTitle: 'Provider Details',
detailCategories: 'Categories',
detailContact: 'Contact',
detailLocation: 'Location',
detailNavigate: 'Navigate',
detailNoCategories: 'No categories assigned',
detailNoPhone: 'No phone',
detailNoEmail: 'No email',
detailNoWebsite: 'No website',
detailNoPlusCode: 'No Plus Code',
},
// ── Categories (Hybrid Category UI) ────────────────────────────────
categories: {
block_a_title: 'Basic Categories (Vehicle & Industry)',
block_b_title: 'Specific Tasks & Tags',
search_placeholder: 'Search for a service, or type a new one (Enter)...',
no_results_create: 'No results. Press Enter to create a new tag.',
},
// ── Gamification ───────────────────────────────────────────────────
gamification: {
points_awarded: 'Saved successfully! You earned {points} XP!',
penalty_applied: 'Saved. Due to your restriction level, you earned {points} XP.',
},
admin: {

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Pénzügyek és Költségek',
diagnostic: 'Élő Diagnosztika',
addVehicle: '+ Új Jármű Hozzáadása',
service: 'Szervíz',
},
header: {
welcome: 'Üdvözlünk',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Autómentő',
filterBenzinkut: '⛽ Benzinkút',
filterAutoKozmetika: '✨ Autókozmetika',
// Category filter (FEATURE 2026-06-17)
categoryPlaceholder: 'Kategória keresése (pl. klíma, gumi)',
categoryFilterTitle: 'Kategória szűrő',
categoryRemove: 'Eltávolítás',
categoryNoResults: 'Nincs találat',
// SOS Tab
sosTabTitle: 'SOS Mentés',
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!',
@@ -1299,21 +1305,60 @@ export default {
duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
// Edit Modal
editTitle: 'Szolgáltató adatok szerkesztése',
tabBasic: 'Alapadatok',
tabServices: 'Szolgáltatások',
addressSection: 'Cím adatok',
contactSection: 'Elérhetőségek',
streetNameLabel: 'Utca neve',
streetNameOptional: 'opcionális',
streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege',
streetTypePlaceholder: 'Válassz típust...',
houseNumberLabel: 'Házszám',
houseNumberPlaceholder: 'Pl. 4',
phoneLabel: 'Telefonszám',
phoneOptional: 'opcionális',
phonePlaceholder: 'Pl. +36 20 123 4567',
emailLabel: 'Email',
emailOptional: 'opcionális',
emailPlaceholder: 'Pl. info@szolgaltato.hu',
websiteLabel: 'Weboldal',
websiteOptional: 'opcionális',
websitePlaceholder: 'Pl. https://szolgaltato.hu',
tagsLabel: 'Szolgáltatás Címkék',
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)',
level0Label: 'Járműtípus',
level1Label: 'Iparág / Főcsoport',
editInfo: 'A szerkesztéseid ellenőrzésre kerülnek, és gamifikációs pontokat szerezhetsz!',
// Plus Code
plusCodeLabel: 'Plus kód',
plusCodeOptional: 'opcionális',
plusCodePlaceholder: 'Pl. 8FVC9X8V+XV',
// Detail Modal
detailTitle: 'Szolgáltató adatai',
detailCategories: 'Kategóriák',
detailContact: 'Elérhetőségek',
detailLocation: 'Helyszín',
detailNavigate: 'Navigáció',
detailNoCategories: 'Nincs kategória hozzárendelve',
detailNoPhone: 'Nincs telefonszám',
detailNoEmail: 'Nincs email',
detailNoWebsite: 'Nincs weboldal',
detailNoPlusCode: 'Nincs Plus kód',
},
// ── Categories (Hybrid Category UI) ────────────────────────────────
categories: {
block_a_title: 'Alap kategóriák (Jármű és Iparág)',
block_b_title: 'Specifikus feladatok és címkék',
search_placeholder: 'Keress szolgáltatást, vagy írj be újat (Enter)...',
no_results_create: 'Nincs találat. Nyomj Entert az új címke létrehozásához.',
},
// ── Gamification ───────────────────────────────────────────────────
gamification: {
points_awarded: 'Sikeres mentés! Szereztél {points} XP-t!',
penalty_applied: 'Mentve. A korlátozásod miatt {points} XP-t kaptál.',
},
admin: {

View File

@@ -66,7 +66,7 @@
{{ t('menu.finance') }}
</button>
<!-- Szerviz kereső -->
<!-- Szerviz kereső (belső) -->
<button
@click="navigateToCard('service')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
@@ -77,6 +77,17 @@
</svg>
{{ t('menu.features') }}
</button>
<!-- Szervíz (külső oldal) -->
<button
@click="openExternalServiceFinder"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('menu.service') }}
</button>
</div>
</div>
</Transition>
@@ -133,6 +144,12 @@ function navigateToCard(cardId: string) {
}
}
// ── Open external Service Finder page in new tab ───────────────────
function openExternalServiceFinder() {
isHamburgerOpen.value = false
window.open('https://app.servicefinder.hu/dashboard/service-finder', '_blank', 'noopener,noreferrer')
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -21,4 +21,10 @@ export interface OrganizationItem {
* Set via PATCH /organizations/{org_id}/visual-settings.
*/
visual_settings?: Record<string, any> | null
/**
* The current user's role in this organization (e.g. OWNER, ADMIN, FLEET_MANAGER, etc.).
* Returned by GET /api/v1/organizations/my since the 2026-06-17 bugfix.
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
*/
user_role?: string
}

View File

@@ -164,37 +164,38 @@
</div>
<!--
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click="router.push('/dashboard/service-finder?mode=planned')"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
</div>
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
@click="openExternalServiceFinder"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click.stop="openExternalServiceFinder"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click.stop="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
</div>
<!--
Card 4: 🏆 Gamification (Játékosítás)
@@ -440,6 +441,14 @@ const openCard = (cardId: string, targetId: string | null = null) => {
const isSearchModalOpen = ref(false)
const isSfQuickAddOpen = ref(false)
/**
* Opens the external Service Finder page in a new tab.
* Used by the card click and the "Tervezett karbantartás" button.
*/
function openExternalServiceFinder() {
window.open('https://app.servicefinder.hu/dashboard/service-finder', '_blank', 'noopener,noreferrer')
}
/**
* Called when ProviderQuickAddModal saves a new provider.
*/

View File

@@ -66,6 +66,90 @@
</button>
</div>
<!-- Category Filter (Autocomplete + Chips) -->
<div class="mx-auto mt-4 max-w-2xl">
<!-- Autocomplete input -->
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<input
v-model="categoryQuery"
type="text"
:placeholder="t('serviceFinder.categoryPlaceholder')"
class="w-full rounded-xl border-2 border-white/20 bg-white/10 py-3 pl-12 pr-10 text-sm text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
@input="onCategoryInput"
@keydown.enter="selectFirstSuggestion"
@keydown.escape="categorySuggestions = []"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
/>
<!-- Clear button -->
<button
v-if="categoryQuery"
@click="clearCategoryInput"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-white transition-colors cursor-pointer"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Autocomplete dropdown -->
<div
v-if="categorySuggestions.length > 0"
class="absolute z-50 mt-1 w-full max-w-md rounded-xl border border-white/20 bg-slate-800 shadow-2xl backdrop-blur-xl"
style="max-width: calc(100% - 2rem);"
>
<div
v-for="(suggestion, idx) in categorySuggestions"
:key="suggestion.id"
@click="selectCategory(suggestion)"
@mouseenter="categoryHighlightIndex = idx"
class="flex cursor-pointer items-center gap-3 px-4 py-3 text-sm text-slate-200 transition-colors"
:class="{
'bg-sf-accent/20 text-white': idx === categoryHighlightIndex,
'hover:bg-white/5': idx !== categoryHighlightIndex,
'rounded-t-xl': idx === 0,
'rounded-b-xl': idx === categorySuggestions.length - 1,
}"
>
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/20 text-xs font-bold text-sf-accent">
{{ suggestion.level }}
</span>
<div class="flex-1">
<span class="font-medium">{{ categoryLabel(suggestion) }}</span>
<span v-if="suggestion.path" class="ml-2 text-xs text-slate-500">#{{ suggestion.path }}</span>
</div>
<span class="text-xs text-slate-500">L{{ suggestion.level }}</span>
</div>
</div>
<!-- Selected category chips -->
<div v-if="selectedCategories.length > 0" class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.categoryFilterTitle') }}:</span>
<span
v-for="cat in selectedCategories"
:key="cat.id"
class="inline-flex items-center gap-1.5 rounded-full bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-white"
>
{{ categoryLabel(cat) }}
<button
@click="removeCategory(cat)"
class="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-300 transition-colors hover:bg-white/20 hover:text-white cursor-pointer"
:title="t('serviceFinder.categoryRemove')"
>
<svg class="h-3 w-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>
<!-- Quick filter chips -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
@@ -240,6 +324,32 @@
</span>
</div>
<!-- Contact info: phone + website (P0 UI Card Cleanup 2026-06-17) -->
<div class="mb-3 flex flex-wrap items-center gap-3 text-sm">
<a
v-if="provider.contact_phone"
:href="'tel:' + provider.contact_phone"
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
>
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{{ provider.contact_phone }}</span>
</a>
<a
v-if="provider.website"
:href="provider.website.startsWith('http') ? provider.website : 'https://' + provider.website"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
>
<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="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>
<span>{{ provider.website.replace(/^https?:\/\//, '') }}</span>
</a>
</div>
<!-- Specialization chips / tags -->
<div
v-if="provider.specialization && provider.specialization.length > 0"
@@ -260,10 +370,36 @@
</span>
</div>
<!-- Category badge ( szolgáltatás - mindig megjelenik) -->
<!-- FEATURE (2026-06-17): Kategória chip-ek a kártyán -->
<div
v-if="provider.categories && provider.categories.length > 0"
class="mb-4 flex flex-wrap gap-1.5"
>
<span
v-for="cat in provider.categories.slice(0, 4)"
:key="cat.id"
class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 border border-emerald-100"
>
{{ providerCategoryLabel(cat) }}
</span>
<span
v-if="provider.categories.length > 4"
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500"
>
+{{ provider.categories.length - 4 }}
</span>
</div>
<!-- Category badge ( szolgáltatás - csak ha van kategória) -->
<div class="mb-3">
<span
v-if="provider.category"
v-if="provider.categories && provider.categories.length > 0"
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
>
{{ providerCategoryLabel(provider.categories[0]) }}
</span>
<span
v-else-if="provider.category"
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
>
{{ provider.category }}
@@ -397,17 +533,35 @@ import api from '@/api/axios'
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
const { t } = useI18n()
const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ──
// @see backend/app/schemas/provider.py:21-33
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
categories: CategoryInfo[]
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
plus_code: 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
@@ -430,12 +584,54 @@ const quickFilters = computed(() => [
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') },
])
/**
* Locale-aware category label.
* Returns name_hu if locale is 'hu', name_en otherwise.
* Falls back to the other language if the preferred one is null.
*/
function categoryLabel(cat: CategorySuggestion): string {
if (locale.value === 'hu') {
return cat.name_hu || cat.name_en || cat.key
}
return cat.name_en || cat.name_hu || cat.key
}
/**
* FEATURE (2026-06-17): Locale-aware category label for provider cards.
* Uses the same logic as categoryLabel but for CategoryInfo type.
*/
function providerCategoryLabel(cat: CategoryInfo): string {
if (locale.value === 'hu') {
return cat.name_hu || cat.name_en || cat.key
}
return cat.name_en || cat.name_hu || cat.key
}
const router = useRouter()
const route = useRoute()
// ── Active Tab (from route.query.mode or default 'planned') ──
const activeTab = ref<'planned' | 'sos'>('planned')
// ── Category Filter State (FEATURE 2026-06-17) ──
// Matching backend CategoryAutocompleteItem schema
// @see backend/app/schemas/provider.py:41-55
interface CategorySuggestion {
id: number
key: string
name_hu: string | null
name_en: string | null
level: number
path: string | null
parent_id: number | null
}
const categoryQuery = ref('')
const categorySuggestions = ref<CategorySuggestion[]>([])
const categoryHighlightIndex = ref(-1)
const selectedCategories = ref<CategorySuggestion[]>([])
let categoryDebounceTimer: ReturnType<typeof setTimeout> | null = null
// ── Reactive state ──
const searchQuery = ref('')
const searchCity = ref('')
@@ -464,21 +660,28 @@ onMounted(() => {
}
})
// ── Search logic (passes both q and city) ──
// ── Helper: build common search params (includes category_ids) ──
function buildSearchParams(offset: number): Record<string, any> {
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
if (selectedCategories.value.length > 0) {
params.category_ids = selectedCategories.value.map(c => c.id).join(',')
}
return params
}
// ── Search logic (passes q, city, and category_ids) ──
async function handleSearch() {
isSearching.value = true
hasSearched.value = true
currentPage.value = 1
try {
const offset = 0
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const params = buildSearchParams(0)
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
@@ -506,13 +709,7 @@ async function goToPage(page: number) {
try {
const offset = (page - 1) * perPage.value
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const params = buildSearchParams(offset)
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
@@ -523,6 +720,82 @@ async function goToPage(page: number) {
}
}
// ── Category Autocomplete (FEATURE 2026-06-17) ──
async function onCategoryInput() {
// Clear previous suggestions if query is too short
if (!categoryQuery.value || categoryQuery.value.trim().length < 2) {
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
// Debounce: wait 250ms after last keystroke
if (categoryDebounceTimer) clearTimeout(categoryDebounceTimer)
categoryDebounceTimer = setTimeout(async () => {
try {
const q = categoryQuery.value.trim()
if (q.length < 2) {
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
const res = await api.get<CategorySuggestion[]>('providers/categories/autocomplete', {
params: { q },
})
categorySuggestions.value = res.data || []
categoryHighlightIndex.value = categorySuggestions.value.length > 0 ? 0 : -1
} catch (err) {
console.error('[ServiceFinderView] Category autocomplete error:', err)
categorySuggestions.value = []
categoryHighlightIndex.value = -1
}
}, 250)
}
function selectCategory(cat: CategorySuggestion) {
// Prevent duplicates
if (selectedCategories.value.some(c => c.id === cat.id)) {
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
selectedCategories.value.push({ ...cat })
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
// Immediately re-trigger search with the new category filter
handleSearch()
}
function removeCategory(cat: CategorySuggestion) {
selectedCategories.value = selectedCategories.value.filter(c => c.id !== cat.id)
// Immediately refresh results when removing a chip
handleSearch()
}
function clearCategoryInput() {
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
}
function selectFirstSuggestion() {
if (categorySuggestions.value.length > 0 && categoryHighlightIndex.value >= 0) {
selectCategory(categorySuggestions.value[categoryHighlightIndex.value])
}
}
function highlightNext() {
if (categorySuggestions.value.length === 0) return
categoryHighlightIndex.value = (categoryHighlightIndex.value + 1) % categorySuggestions.value.length
}
function highlightPrev() {
if (categorySuggestions.value.length === 0) return
categoryHighlightIndex.value = (categoryHighlightIndex.value - 1 + categorySuggestions.value.length) % categorySuggestions.value.length
}
// ── Detail Modal ──
function openDetail(provider: ProviderSearchResult) {
selectedProvider.value = provider