céges meghívó kezelése,
This commit is contained in:
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>×</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"
|
||||
>×</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
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 (fő 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 (fő 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
|
||||
|
||||
Reference in New Issue
Block a user