refaktor címjegyzék

This commit is contained in:
Roo
2026-07-02 11:52:22 +00:00
parent 7654913d21
commit 07b59032ce
192 changed files with 2936 additions and 1376 deletions

View File

@@ -0,0 +1,123 @@
<template>
<div>
<!-- Empty state -->
<div v-if="!data || isEmpty(data)" class="text-center py-8">
<svg class="w-10 h-10 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-sm text-slate-500">Nincs nyers adat</p>
</div>
<!-- JSON tree view -->
<div v-else class="space-y-1 font-mono text-xs">
<div
v-for="(value, key) in data"
:key="key"
class="border-b border-slate-700/50 last:border-0"
>
<div class="flex items-start gap-3 py-2">
<!-- Key -->
<span class="text-indigo-400 font-semibold whitespace-nowrap min-w-[140px] flex-shrink-0">
{{ key }}
</span>
<!-- Value -->
<div class="flex-1 min-w-0">
<!-- Null -->
<span v-if="value === null || value === undefined" class="text-slate-500 italic">null</span>
<!-- String -->
<span v-else-if="typeof value === 'string'" class="text-emerald-300 break-words">
"{{ value }}"
</span>
<!-- Number / Boolean -->
<span v-else-if="typeof value === 'number' || typeof value === 'boolean'" class="text-amber-300">
{{ value }}
</span>
<!-- Array -->
<div v-else-if="Array.isArray(value)">
<template v-if="value.length === 0">
<span class="text-slate-500 italic">[]</span>
</template>
<template v-else>
<details class="group">
<summary class="cursor-pointer text-slate-400 hover:text-slate-300 list-none flex items-center gap-1.5">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="text-slate-400">Array [{{ value.length }}]</span>
</summary>
<div class="ml-4 mt-1 space-y-1 border-l border-slate-700 pl-3">
<div v-for="(item, idx) in value" :key="idx" class="py-1">
<span class="text-slate-500 mr-2">[{{ idx }}]</span>
<span v-if="typeof item === 'object' && item !== null" class="text-slate-400">
<RawDataViewer :data="item" />
</span>
<span v-else-if="typeof item === 'string'" class="text-emerald-300">"{{ item }}"</span>
<span v-else class="text-amber-300">{{ item }}</span>
</div>
</div>
</details>
</template>
</div>
<!-- Object (nested JSON) -->
<div v-else-if="typeof value === 'object'">
<details class="group">
<summary class="cursor-pointer text-slate-400 hover:text-slate-300 list-none flex items-center gap-1.5">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="text-slate-400">Object {{ objectKeys(value) }}</span>
</summary>
<div class="ml-4 mt-1 border-l border-slate-700 pl-3">
<RawDataViewer :data="value" />
</div>
</details>
</div>
<!-- Fallback -->
<span v-else class="text-slate-400">{{ String(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* RawDataViewer.vue
*
* Egy Vue 3 komponens, amely egy JSON/objektum adatot jelenít meg
* strukturált, fa-szerű nézetben. Támogatja a beágyazott objektumokat
* és tömböket összecsukható (details/summary) elemekkel.
*
* Props:
* data - Object | null - A megjelenítendő nyers adat (JSON)
*/
defineProps<{
data: Record<string, any> | null
}>()
/**
* Ellenőrzi, hogy egy objektum üres-e (nincs saját enumerable tulajdonsága).
*/
function isEmpty(obj: Record<string, any>): boolean {
if (!obj || typeof obj !== 'object') return true
return Object.keys(obj).length === 0
}
/**
* Visszaadja az objektum kulcsainak számát szöveges formában,
* pl. "{ 3 kulcs }" vagy "{ üres }"
*/
function objectKeys(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object') return '{ }'
const keys = Object.keys(obj)
if (keys.length === 0) return '{ üres }'
return `{ ${keys.length} kulcs }`
}
</script>

View File

@@ -0,0 +1,229 @@
<template>
<div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">Cím adatok</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Smart Location Input (zip + city autocomplete) -->
<div class="md:col-span-2">
<SmartLocationInput
:model-value="selectedLocation"
@update:model-value="onLocationSelected"
label="Irányítószám / Város"
placeholder="Kezdj el gépelni (pl. 1011 vagy Budapest)..."
/>
</div>
<!-- Street name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Utca neve</label>
<input
:value="modelValue.street_name"
@input="updateField('street_name', ($event.target as HTMLInputElement).value)"
placeholder="pl. Egressy"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Street type -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Közterület jellege</label>
<select
:value="modelValue.street_type"
@change="updateField('street_type', ($event.target as HTMLSelectElement).value)"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="">Válassz...</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="köz">köz</option>
<option value="sétány">sétány</option>
<option value="lakópark">lakópark</option>
<option value="liget">liget</option>
<option value="erdő">erdő</option>
<option value="dűlő">dűlő</option>
<option value="major">major</option>
<option value="puszta">puszta</option>
<option value="sziget">sziget</option>
<option value="telep">telep</option>
</select>
</div>
<!-- House number -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Házszám</label>
<input
:value="modelValue.house_number"
@input="updateField('house_number', ($event.target as HTMLInputElement).value)"
placeholder="pl. 4/b"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Stairwell -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Lépcsőház</label>
<input
:value="modelValue.stairwell"
@input="updateField('stairwell', ($event.target as HTMLInputElement).value)"
placeholder="pl. 1"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Floor -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Emelet</label>
<input
:value="modelValue.floor"
@input="updateField('floor', ($event.target as HTMLInputElement).value)"
placeholder="pl. 3"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Door -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Ajtó</label>
<input
:value="modelValue.door"
@input="updateField('door', ($event.target as HTMLInputElement).value)"
placeholder="pl. A"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Parcel ID (hrsz) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Helyrajzi szám (hrsz)</label>
<input
:value="modelValue.parcel_id"
@input="updateField('parcel_id', ($event.target as HTMLInputElement).value)"
placeholder="pl. 12345/6"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* SharedAddressForm.vue
* ======================
*
* Reusable address form component that uses SmartLocationInput for
* zip/city autocomplete and provides all other address fields.
*
* Usage:
* <SharedAddressForm v-model="form.address" />
*
* The v-model expects an object matching the AddressIn schema:
* {
* zip?: string
* city?: string
* street_name?: string
* street_type?: string
* house_number?: string
* stairwell?: string
* floor?: string
* door?: string
* parcel_id?: string
* full_address_text?: string
* latitude?: number
* longitude?: number
* }
*/
import { ref, computed } from 'vue'
interface AddressForm {
zip?: string | null
city?: string | null
street_name?: string | null
street_type?: string | null
house_number?: string | null
stairwell?: string | null
floor?: string | null
door?: string | null
parcel_id?: string | null
full_address_text?: string | null
latitude?: number | null
longitude?: number | null
}
interface LocationItem {
id: number
zip_code: string
city: string
country_code: string
}
const props = withDefaults(defineProps<{
modelValue?: AddressForm
}>(), {
modelValue: () => ({
zip: null,
city: null,
street_name: null,
street_type: null,
house_number: null,
stairwell: null,
floor: null,
door: null,
parcel_id: null,
full_address_text: null,
latitude: null,
longitude: null,
}),
})
const emit = defineEmits<{
'update:modelValue': [value: AddressForm]
}>()
/**
* Computed property for the SmartLocationInput selected value.
* Converts the address zip/city to a LocationItem if both are present.
*/
const selectedLocation = computed<LocationItem | null>(() => {
if (props.modelValue?.zip && props.modelValue?.city) {
return {
id: 0, // We don't have the DB id here, but SmartLocationInput handles this
zip_code: props.modelValue.zip,
city: props.modelValue.city,
country_code: 'HU',
}
}
return null
})
/**
* Handle location selection from SmartLocationInput.
* Updates the zip and city fields in the address model.
*/
function onLocationSelected(location: LocationItem | null) {
const updated = { ...props.modelValue }
if (location) {
updated.zip = location.zip_code
updated.city = location.city
} else {
updated.zip = null
updated.city = null
}
emit('update:modelValue', updated)
}
/**
* Update a single field in the address model.
*/
function updateField(field: keyof AddressForm, value: string) {
const updated = { ...props.modelValue }
;(updated as any)[field] = value || null
emit('update:modelValue', updated)
}
</script>

View File

@@ -0,0 +1,323 @@
<template>
<div class="relative">
<label v-if="label" class="block text-sm font-medium text-slate-300 mb-1">{{ label }}</label>
<div class="relative">
<input
ref="inputRef"
:value="displayText"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
@keydown.enter.prevent="selectHighlighted"
@keydown.escape="closeDropdown"
:placeholder="placeholder"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
<!-- Loading spinner -->
<svg
v-if="searching"
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-slate-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<!-- Clear button -->
<button
v-if="selectedItem && !searching"
@click="clearSelection"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Dropdown -->
<div
v-if="showDropdown && filteredItems.length > 0"
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-60 overflow-y-auto"
>
<button
v-for="(item, index) in filteredItems"
:key="item.id"
@mousedown.prevent="selectItem(item)"
class="w-full text-left px-3 py-2 text-sm transition flex items-center gap-2"
:class="index === highlightedIndex
? 'bg-indigo-500/20 text-white'
: 'text-slate-300 hover:bg-slate-700'"
>
<span class="font-mono text-indigo-400">{{ item.zip_code }}</span>
<span>{{ item.city }}</span>
<span class="text-xs text-slate-500 ml-auto">{{ item.country_code }}</span>
</button>
</div>
<!-- No results -->
<div
v-if="showDropdown && query.length >= minChars && filteredItems.length === 0 && !searching"
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl p-3 text-sm text-slate-400"
>
Nincs találat
</div>
</div>
</template>
<script setup lang="ts">
/**
* SmartLocationInput.vue
* ======================
*
* Autocomplete/combobox component for selecting a city/zip code pair.
* Calls the backend GET /api/v1/locations/autocomplete endpoint.
*
* Usage:
* <SmartLocationInput v-model="selected" />
*
* The v-model emits the selected GeoPostalCode object:
* { id: number, zip_code: string, city: string, country_code: string }
*
* Props:
* modelValue: The currently selected location (null or the object)
* label: Optional label text above the input
* placeholder: Placeholder text
* minChars: Minimum characters before triggering search (default: 2)
* countryCode: Filter by country code (default: 'HU')
* debounceMs: Debounce delay in ms (default: 300)
*/
import { ref, computed, watch, nextTick } from 'vue'
interface LocationItem {
id: number
zip_code: string
city: string
country_code: string
}
const props = withDefaults(defineProps<{
modelValue?: LocationItem | null
label?: string
placeholder?: string
minChars?: number
countryCode?: string
debounceMs?: number
}>(), {
modelValue: null,
label: '',
placeholder: 'Kezdj el gépelni (irányítószám vagy város)...',
minChars: 2,
countryCode: 'HU',
debounceMs: 300,
})
const emit = defineEmits<{
'update:modelValue': [value: LocationItem | null]
'select': [value: LocationItem]
}>()
// ── State ──────────────────────────────────────────────
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const selectedItem = ref<LocationItem | null>(props.modelValue)
const items = ref<LocationItem[]>([])
const searching = ref(false)
const showDropdown = ref(false)
const highlightedIndex = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// ── Computed ───────────────────────────────────────────
/**
* The display text shown in the input.
* If a location is selected, shows "zip_code city".
* Otherwise shows the raw query text.
*/
const displayText = computed(() => {
if (selectedItem.value) {
return `${selectedItem.value.zip_code} ${selectedItem.value.city}`
}
return query.value
})
/**
* Filtered items based on the current query.
* The backend already filters, but we keep client-side filtering
* for instant feedback when the user types more after results load.
*/
const filteredItems = computed(() => {
if (!query.value || query.value.length < props.minChars) return []
const q = query.value.toLowerCase()
return items.value.filter(
(item) =>
item.zip_code.toLowerCase().includes(q) ||
item.city.toLowerCase().includes(q)
)
})
// ── Watch modelValue from parent ───────────────────────
watch(
() => props.modelValue,
(val) => {
selectedItem.value = val
}
)
// ── Methods ────────────────────────────────────────────
/**
* Handle input events from the text field.
* Starts debounced search when the user types.
*/
function onInput(event: Event) {
const target = event.target as HTMLInputElement
query.value = target.value
// If the user is typing, clear the selected item
if (selectedItem.value) {
selectedItem.value = null
emit('update:modelValue', null)
}
// Debounced search
if (debounceTimer) clearTimeout(debounceTimer)
if (query.value.length >= props.minChars) {
debounceTimer = setTimeout(() => {
searchLocations(query.value)
}, props.debounceMs)
} else {
items.value = []
showDropdown.value = false
}
}
/**
* Call the backend autocomplete API.
*/
async function searchLocations(searchQuery: string) {
if (!searchQuery || searchQuery.length < props.minChars) return
searching.value = true
try {
const res = await $fetch('/api/v1/locations/autocomplete', {
params: {
q: searchQuery,
country_code: props.countryCode,
limit: 15,
},
headers: getHeaders(),
})
items.value = (res as LocationItem[]) || []
highlightedIndex.value = 0
showDropdown.value = items.value.length > 0
} catch (err: any) {
console.error('Location autocomplete error:', err)
items.value = []
} finally {
searching.value = false
}
}
/**
* Get auth headers for API calls.
*/
function getHeaders(): Record<string, string> {
const token = useCookie('access_token').value
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
return headers
}
/**
* Select an item from the dropdown.
*/
function selectItem(item: LocationItem) {
selectedItem.value = item
query.value = ''
showDropdown.value = false
emit('update:modelValue', item)
emit('select', item)
}
/**
* Clear the current selection.
*/
function clearSelection() {
selectedItem.value = null
query.value = ''
items.value = []
showDropdown.value = false
emit('update:modelValue', null)
nextTick(() => {
inputRef.value?.focus()
})
}
/**
* Close the dropdown.
*/
function closeDropdown() {
showDropdown.value = false
}
/**
* Handle focus - show dropdown if there are results.
*/
function onFocus() {
if (filteredItems.value.length > 0) {
showDropdown.value = true
}
}
/**
* Handle blur - close dropdown with a delay to allow click.
*/
function onBlur() {
// Delay to allow mousedown on dropdown items
setTimeout(() => {
showDropdown.value = false
}, 200)
}
/**
* Highlight the next item in the dropdown.
*/
function highlightNext() {
if (filteredItems.value.length > 0) {
highlightedIndex.value = (highlightedIndex.value + 1) % filteredItems.value.length
}
}
/**
* Highlight the previous item in the dropdown.
*/
function highlightPrev() {
if (filteredItems.value.length > 0) {
highlightedIndex.value = (highlightedIndex.value - 1 + filteredItems.value.length) % filteredItems.value.length
}
}
/**
* Select the currently highlighted item.
*/
function selectHighlighted() {
if (filteredItems.value.length > 0 && highlightedIndex.value >= 0) {
selectItem(filteredItems.value[highlightedIndex.value])
}
}
</script>
<style scoped>
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.5);
border-radius: 3px;
}
</style>