Radius/add network structure
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* TtDataTable - Enhanced data table with loading states (Vue 3)
|
||||
* Modern, reusable table component with placeholders and skeletons
|
||||
*/
|
||||
|
||||
const TtDataTable = {
|
||||
name: 'TtDataTable',
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasSearched: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
density: {
|
||||
type: String,
|
||||
default: 'compact',
|
||||
validator: (value) => ['compact', 'ultra-compact', 'normal'].includes(value)
|
||||
},
|
||||
tableClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
tableStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
tableMinHeight: {
|
||||
type: String,
|
||||
default: 'auto'
|
||||
},
|
||||
initialPlaceholderIcon: {
|
||||
type: String,
|
||||
default: 'fa-duotone fa-keyboard'
|
||||
},
|
||||
initialPlaceholderText: {
|
||||
type: String,
|
||||
default: 'Beginnen Sie Ihre Suche.'
|
||||
},
|
||||
noResultsPlaceholderIcon: {
|
||||
type: String,
|
||||
default: 'fa-duotone fa-database'
|
||||
},
|
||||
noResultsPlaceholderText: {
|
||||
type: String,
|
||||
default: 'Keine Ergebnisse gefunden.'
|
||||
},
|
||||
skeletonRowCount: {
|
||||
type: Number,
|
||||
default: 6
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="tt-scope table-view-wrapper">
|
||||
<!-- Initial state: Not yet searched -->
|
||||
<div v-if="!hasSearched" class="table-placeholder" :style="{minHeight: tableMinHeight}">
|
||||
<i :class="initialPlaceholderIcon"></i>
|
||||
<div>{{ initialPlaceholderText }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="isLoading">
|
||||
<slot name="loading-placeholder">
|
||||
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
|
||||
<table class="tt-table" :class="[density, tableClass]">
|
||||
<slot name="head"></slot>
|
||||
<tbody>
|
||||
<tr v-for="n in skeletonRowCount" :key="'skel'+n">
|
||||
<slot name="skeleton-row"></slot>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- No results state -->
|
||||
<div v-else-if="!items.length" class="table-placeholder" :style="{minHeight: tableMinHeight}">
|
||||
<i :class="noResultsPlaceholderIcon"></i>
|
||||
<div>{{ noResultsPlaceholderText }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Data state -->
|
||||
<template v-else>
|
||||
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
|
||||
<table class="tt-table" :class="[density, tableClass]">
|
||||
<slot name="head"></slot>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in items" :key="index" class="row-fade-in">
|
||||
<slot name="row" :item="item" :index="index"></slot>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<slot name="observer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-data-table', TtDataTable);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* TtStatusChip - Smart online status chip with lazy loading (Vue 3)
|
||||
* Displays online/offline status with IP address and copy functionality
|
||||
*/
|
||||
|
||||
const TtStatusChip = {
|
||||
name: 'TtStatusChip',
|
||||
props: {
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
apiEndpoint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['scan-ip'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted, onBeforeUnmount, watch } = Vue;
|
||||
|
||||
const data = ref(null);
|
||||
const observed = ref(false);
|
||||
const observer = ref(null);
|
||||
const isHovering = ref(false);
|
||||
const ctrlPressed = ref(false);
|
||||
const tooltipText = ref('IP-Adresse kopieren');
|
||||
const root = ref(null);
|
||||
|
||||
watch(data, (newData) => {
|
||||
if (newData && newData.ip) {
|
||||
tooltipText.value = 'IP-Adresse kopieren';
|
||||
} else {
|
||||
tooltipText.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const endpoint = props.apiEndpoint || `${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(props.username)}`;
|
||||
const response = await fetch(endpoint);
|
||||
data.value = response.ok ? await response.json() : { online: false, ip: null };
|
||||
} catch {
|
||||
data.value = { online: false, ip: null };
|
||||
}
|
||||
};
|
||||
|
||||
const copyIp = async (event) => {
|
||||
if (!data.value?.ip) return;
|
||||
|
||||
const element = event.currentTarget;
|
||||
if (!element || element.classList.contains('is-copied')) return;
|
||||
|
||||
// Copy to clipboard
|
||||
if (window.TT_CORE && window.TT_CORE.copyToClipboard) {
|
||||
await window.TT_CORE.copyToClipboard(data.value.ip);
|
||||
}
|
||||
|
||||
// Visual feedback
|
||||
element.classList.add('is-copied');
|
||||
const originalTooltip = tooltipText.value;
|
||||
tooltipText.value = 'Kopiert!';
|
||||
|
||||
setTimeout(() => {
|
||||
element.classList.remove('is-copied');
|
||||
tooltipText.value = originalTooltip;
|
||||
updateTooltip();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleKey = (event) => {
|
||||
const newCtrlPressed = event.ctrlKey || event.metaKey;
|
||||
if (newCtrlPressed !== ctrlPressed.value) {
|
||||
ctrlPressed.value = newCtrlPressed;
|
||||
if (isHovering.value) {
|
||||
updateTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onIpMouseOver = (event) => {
|
||||
isHovering.value = true;
|
||||
ctrlPressed.value = event.ctrlKey || event.metaKey;
|
||||
updateTooltip();
|
||||
};
|
||||
|
||||
const onIpMouseOut = () => {
|
||||
isHovering.value = false;
|
||||
ctrlPressed.value = false;
|
||||
updateTooltip();
|
||||
};
|
||||
|
||||
const updateTooltip = () => {
|
||||
if (!data.value?.ip) {
|
||||
tooltipText.value = null;
|
||||
} else if (isHovering.value && ctrlPressed.value) {
|
||||
tooltipText.value = 'Scan starten & verbinden';
|
||||
} else {
|
||||
tooltipText.value = 'IP-Adresse kopieren';
|
||||
}
|
||||
};
|
||||
|
||||
const onClickIp = (event) => {
|
||||
if (!data.value?.ip) return;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl+Click: emit scan event
|
||||
event.preventDefault();
|
||||
emit('scan-ip', { ip: data.value.ip });
|
||||
} else {
|
||||
// Normal click: copy IP
|
||||
copyIp(event);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Setup intersection observer for lazy loading
|
||||
observer.value = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !observed.value) {
|
||||
observed.value = true;
|
||||
fetchState();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (root.value) {
|
||||
observer.value.observe(root.value);
|
||||
}
|
||||
|
||||
// Listen for Ctrl/Meta key
|
||||
document.addEventListener('keydown', handleKey);
|
||||
document.addEventListener('keyup', handleKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
document.removeEventListener('keyup', handleKey);
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
tooltipText,
|
||||
root,
|
||||
onClickIp,
|
||||
onIpMouseOver,
|
||||
onIpMouseOut
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="tt-scope status-chip-wrap" ref="root">
|
||||
<!-- Loading skeleton -->
|
||||
<span v-if="data === null" class="status-chip skeleton">
|
||||
<span class="dot"></span>
|
||||
<span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span>
|
||||
</span>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<span
|
||||
v-else
|
||||
class="status-chip"
|
||||
:class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]"
|
||||
:data-tooltip="tooltipText"
|
||||
@click="onClickIp"
|
||||
@mouseover="onIpMouseOver"
|
||||
@mouseout="onIpMouseOut"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="ip">{{ data.ip || '—' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-status-chip', TtStatusChip);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* TtInfoCard Component
|
||||
*
|
||||
* A reusable info card component for displaying key-value pairs with optional copy button.
|
||||
* Commonly used in router management and other information displays.
|
||||
*
|
||||
* @prop {String} icon - Font Awesome icon class (e.g., 'fa-microchip')
|
||||
* @prop {String} label - The label text
|
||||
* @prop {String|Number} value - The value to display (null/undefined shows loading state)
|
||||
* @prop {Boolean} loading - Explicit loading state (default: false)
|
||||
* @prop {Boolean} copyable - Whether to show copy button when value exists (default: true)
|
||||
* @prop {String} skeletonHeight - Height of skeleton loader (default: '29px')
|
||||
*/
|
||||
|
||||
const TtInfoCard = {
|
||||
name: 'TtInfoCard',
|
||||
props: {
|
||||
icon: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
copyable: { type: Boolean, default: true },
|
||||
skeletonHeight: { type: String, default: '29px' }
|
||||
},
|
||||
template: `
|
||||
<div class="router-info-card">
|
||||
<div class="info-card-label">
|
||||
<i :class="['fa-duotone', icon]"></i>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="info-card-value">
|
||||
<code v-if="!loading && !isValueEmpty">{{ value }}</code>
|
||||
<code v-else-if="!loading">—</code>
|
||||
<tt-skeleton v-else :height="skeletonHeight" />
|
||||
<tt-copy-button
|
||||
v-if="!loading && !isValueEmpty && copyable"
|
||||
:text="String(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
computed: {
|
||||
isValueEmpty() {
|
||||
return this.value === null || this.value === undefined || this.value === '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-info-card', TtInfoCard);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TtLoadingIndicator - Processing indicator with progress (Vue 3)
|
||||
* Displays loading state with animated icon and progress bar
|
||||
*/
|
||||
|
||||
const TtLoadingIndicator = {
|
||||
name: 'TtLoadingIndicator',
|
||||
props: {
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
validator: (value) => value >= 0 && value <= 100
|
||||
},
|
||||
currentRow: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalRows: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentItem: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Verarbeitung läuft...'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'fa-duotone fa-hourglass-half'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="tt-scope table-placeholder">
|
||||
<i
|
||||
:class="[icon, 'animated-hourglass']"
|
||||
style="font-size: 36px; margin-bottom: 10px; color: var(--tt-brand-blue);"
|
||||
></i>
|
||||
<div class="h5">{{ title }}</div>
|
||||
<slot name="description">
|
||||
<p v-if="currentItem" class="muted small">
|
||||
Aktuell: {{ currentItem }}
|
||||
</p>
|
||||
</slot>
|
||||
<div
|
||||
class="progress-bar mt-3"
|
||||
style="width: 250px; margin-left: auto; margin-right: auto;"
|
||||
>
|
||||
<div class="bar" :style="{width: progress + '%'}"></div>
|
||||
</div>
|
||||
<div v-if="totalRows > 0" class="muted small mt-2">
|
||||
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-loading-indicator', TtLoadingIndicator);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* TtSkeleton - Skeleton loader component (Vue 3)
|
||||
* Displays animated loading skeleton
|
||||
*/
|
||||
|
||||
const TtSkeleton = {
|
||||
name: 'TtSkeleton',
|
||||
props: {
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '12px'
|
||||
},
|
||||
borderRadius: {
|
||||
type: String,
|
||||
default: '8px'
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
spacing: {
|
||||
type: String,
|
||||
default: '8px'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="tt-scope">
|
||||
<div
|
||||
v-for="n in count"
|
||||
:key="n"
|
||||
class="skeleton-line"
|
||||
:style="{
|
||||
width: width,
|
||||
'--h': height,
|
||||
borderRadius: borderRadius,
|
||||
marginBottom: n < count ? spacing : '0'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-skeleton', TtSkeleton);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const TtCopyButton = {
|
||||
name: 'TtCopyButton',
|
||||
props: {
|
||||
text: { type: String, required: true },
|
||||
size: { type: String, default: 'sm' }, // 'sm' or 'md'
|
||||
tooltip: { type: String, default: 'Kopieren' },
|
||||
tooltipAlign: { type: String, default: 'bottom' }
|
||||
},
|
||||
template: `
|
||||
<button
|
||||
class="icon-btn"
|
||||
:class="[size, { 'is-copied': isCopied }]"
|
||||
:data-tooltip="isCopied ? 'Kopiert!' : tooltip"
|
||||
:data-tooltip-align="tooltipAlign"
|
||||
@click="copy"
|
||||
:disabled="isCopied"
|
||||
>
|
||||
<i class="fa-duotone fa-copy copy-icon"></i>
|
||||
<i class="fa-duotone fa-check check-icon"></i>
|
||||
</button>
|
||||
`,
|
||||
data: () => ({
|
||||
isCopied: false
|
||||
}),
|
||||
methods: {
|
||||
async copy() {
|
||||
if (this.isCopied) return;
|
||||
|
||||
try {
|
||||
await window.TT_CORE.copyToClipboard(this.text);
|
||||
this.isCopied = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this.isCopied = false;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
window.notify?.('error', 'Kopieren fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-copy-button', TtCopyButton);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* TtFileDropzone - Drag & drop file upload (Vue 3)
|
||||
* Modern file upload component with drag-and-drop support
|
||||
*/
|
||||
|
||||
const TtFileDropzone = {
|
||||
name: 'TtFileDropzone',
|
||||
props: {
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.xlsx'
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: 'Datei auswählen'
|
||||
},
|
||||
dropText: {
|
||||
type: String,
|
||||
default: 'Hierhin ziehen oder'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'fa-duotone fa-cloud-arrow-up'
|
||||
}
|
||||
},
|
||||
emits: ['file-selected'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, computed } = Vue;
|
||||
|
||||
const dragCounter = ref(0);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const isDragging = computed(() => dragCounter.value > 0);
|
||||
|
||||
const onDrop = (event) => {
|
||||
dragCounter.value = 0;
|
||||
const files = event.dataTransfer.files;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const payload = props.multiple ? files : files[0];
|
||||
emit('file-selected', payload);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (event) => {
|
||||
const files = event.target.files;
|
||||
const payload = props.multiple ? files : files[0];
|
||||
emit('file-selected', payload);
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
return {
|
||||
dragCounter,
|
||||
fileInput,
|
||||
isDragging,
|
||||
onDrop,
|
||||
onFileChange,
|
||||
openFilePicker
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<label
|
||||
class="tt-scope file-drop"
|
||||
:class="{'is-dragover': isDragging}"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragCounter++"
|
||||
@dragleave.prevent="dragCounter--"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="onFileChange"
|
||||
hidden
|
||||
ref="fileInput"
|
||||
>
|
||||
<div class="file-cta">
|
||||
<i :class="icon"></i>
|
||||
<div>
|
||||
{{ dropText }}
|
||||
<button
|
||||
type="button"
|
||||
class="link-btn"
|
||||
@click.prevent="openFilePicker"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-file-dropzone', TtFileDropzone);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* TtSmartAutocomplete - Smart autocomplete with mode switching (Vue 3)
|
||||
* Advanced autocomplete component with XINON/ESTMK mode switching
|
||||
*/
|
||||
|
||||
const TtSmartAutocomplete = {
|
||||
name: 'TtSmartAutocomplete',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechnungsadresse suchen'
|
||||
},
|
||||
wide: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
apiEndpoint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'select', 'change', 'enter', 'mode-change'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, computed, watch, onMounted, nextTick } = Vue;
|
||||
|
||||
const q = ref(props.modelValue || '');
|
||||
const open = ref(false);
|
||||
const items = ref({});
|
||||
const highlighted = ref(-1);
|
||||
const busy = ref(false);
|
||||
const mode = ref('autocomplete');
|
||||
const logoDropdownOpen = ref(false);
|
||||
const hasMoreResults = ref(false);
|
||||
const mainInput = ref(null);
|
||||
const resultsList = ref(null);
|
||||
|
||||
let debouncedFetch = null;
|
||||
|
||||
const highlightedId = computed(() => {
|
||||
const keys = Object.keys(items.value);
|
||||
return keys[highlighted.value] || null;
|
||||
});
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
return mode.value === 'autocomplete'
|
||||
? (props.placeholder || 'Rechnungsadresse suchen')
|
||||
: 'Partner-Kundennummer eingeben';
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val !== q.value) {
|
||||
q.value = val;
|
||||
if (mode.value === 'autocomplete') {
|
||||
debouncedFetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const debounce = (fn, ms) => {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
};
|
||||
|
||||
const fetchItems = async () => {
|
||||
if (mode.value !== 'autocomplete' || !q.value || q.value.length < 2) {
|
||||
items.value = {};
|
||||
hasMoreResults.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
const endpoint = props.apiEndpoint || `${window.TT_CONFIG.BASE_PATH}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(q.value)}`;
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
const addresses = json?.result?.addresses || {};
|
||||
|
||||
if (addresses.more) {
|
||||
hasMoreResults.value = true;
|
||||
delete addresses.more;
|
||||
} else {
|
||||
hasMoreResults.value = false;
|
||||
}
|
||||
|
||||
items.value = addresses;
|
||||
highlighted.value = 0;
|
||||
} else {
|
||||
items.value = {};
|
||||
hasMoreResults.value = false;
|
||||
}
|
||||
} catch {
|
||||
items.value = {};
|
||||
hasMoreResults.value = false;
|
||||
}
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const toggleLogoDropdown = () => {
|
||||
logoDropdownOpen.value = !logoDropdownOpen.value;
|
||||
if (logoDropdownOpen.value) open.value = false;
|
||||
};
|
||||
|
||||
const selectMode = (m) => {
|
||||
if (mode.value !== m) {
|
||||
mode.value = m;
|
||||
emit('mode-change', m);
|
||||
clear();
|
||||
}
|
||||
logoDropdownOpen.value = false;
|
||||
nextTick(() => mainInput.value?.focus());
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
emit('update:modelValue', q.value);
|
||||
if (mode.value === 'autocomplete') {
|
||||
debouncedFetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnter = () => {
|
||||
if (mode.value === 'autocomplete') {
|
||||
chooseHighlighted(true);
|
||||
} else {
|
||||
emit('enter');
|
||||
}
|
||||
};
|
||||
|
||||
const maybeOpen = () => {
|
||||
open.value = true;
|
||||
if (q.value) debouncedFetch();
|
||||
};
|
||||
|
||||
const deferClose = () => {
|
||||
setTimeout(() => {
|
||||
open.value = false;
|
||||
logoDropdownOpen.value = false;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
q.value = '';
|
||||
items.value = {};
|
||||
highlighted.value = -1;
|
||||
emitSelection('', '');
|
||||
if (mode.value === 'autocomplete') {
|
||||
open.value = true;
|
||||
debouncedFetch();
|
||||
}
|
||||
};
|
||||
|
||||
const move = (direction) => {
|
||||
const keys = Object.keys(items.value);
|
||||
if (!keys.length) return;
|
||||
highlighted.value = (highlighted.value + direction + keys.length) % keys.length;
|
||||
nextTick(() => {
|
||||
const active = resultsList.value?.querySelector('.is-active');
|
||||
if (active) active.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
});
|
||||
};
|
||||
|
||||
const chooseHighlighted = (enterPressed) => {
|
||||
const id = highlightedId.value;
|
||||
if (id) {
|
||||
choose(id, items.value[id], enterPressed);
|
||||
} else if (enterPressed) {
|
||||
emit('enter');
|
||||
}
|
||||
};
|
||||
|
||||
const choose = (id, display, emitEnter) => {
|
||||
const custnum = (display.match(/\[(\d+)\]/) || [])[1] || '';
|
||||
emitSelection(custnum, display);
|
||||
open.value = false;
|
||||
if (emitEnter) emit('enter');
|
||||
};
|
||||
|
||||
const emitSelection = (custnum, display) => {
|
||||
emit('select', { custnum, display });
|
||||
emit('update:modelValue', display);
|
||||
emit('change', display);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
debouncedFetch = debounce(fetchItems, 220);
|
||||
});
|
||||
|
||||
return {
|
||||
q,
|
||||
open,
|
||||
items,
|
||||
highlighted,
|
||||
busy,
|
||||
mode,
|
||||
logoDropdownOpen,
|
||||
hasMoreResults,
|
||||
mainInput,
|
||||
resultsList,
|
||||
highlightedId,
|
||||
placeholderText,
|
||||
toggleLogoDropdown,
|
||||
selectMode,
|
||||
onInput,
|
||||
onEnter,
|
||||
maybeOpen,
|
||||
deferClose,
|
||||
clear,
|
||||
move,
|
||||
choose
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="tt-scope ac-root"
|
||||
:data-wide="wide ? '1' : null"
|
||||
@keydown.down.prevent="mode === 'autocomplete' && move(1)"
|
||||
@keydown.up.prevent="mode === 'autocomplete' && move(-1)"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
>
|
||||
<span class="ac-focus-tooltip">Klicken Sie auf das Logo, um die Kundenbasis zu wechseln</span>
|
||||
|
||||
<div class="input-wrap">
|
||||
<!-- Logo switcher -->
|
||||
<div
|
||||
class="logo-switcher"
|
||||
@mousedown.prevent.stop="toggleLogoDropdown"
|
||||
:class="{'is-open': logoDropdownOpen}"
|
||||
>
|
||||
<img
|
||||
v-if="mode === 'autocomplete'"
|
||||
src="/img/xinon-logo.png"
|
||||
class="input-icon-logo"
|
||||
alt="Xinon Logo"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
src="/img/estmk_logo.png"
|
||||
class="input-icon-logo"
|
||||
alt="ESTMK Logo"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-down switcher-caret"></i>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<input
|
||||
ref="mainInput"
|
||||
:placeholder="placeholderText"
|
||||
class="ri"
|
||||
v-model="q"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
@input="onInput"
|
||||
@focus="mode === 'autocomplete' && maybeOpen()"
|
||||
@blur="deferClose"
|
||||
/>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="q"
|
||||
class="btn-clear"
|
||||
@mousedown.prevent="clear"
|
||||
title="Feld leeren"
|
||||
>
|
||||
<i class="fa-duotone fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logo dropdown -->
|
||||
<transition name="ac-pop">
|
||||
<div v-if="logoDropdownOpen" class="logo-dropdown">
|
||||
<div class="logo-option" @mousedown.prevent="selectMode('autocomplete')">
|
||||
<img src="/img/xinon-logo.png" alt="Xinon Logo">
|
||||
<span>XINON (Suche)</span>
|
||||
</div>
|
||||
<div class="logo-option" @mousedown.prevent="selectMode('text')">
|
||||
<img src="/img/estmk_logo.png" alt="ESTMK Logo">
|
||||
<span>ESTMK (Eingabe)</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Autocomplete panel -->
|
||||
<transition name="ac-pop">
|
||||
<div v-if="open && mode === 'autocomplete'" class="ac-panel" :class="{'wide': wide}">
|
||||
<div v-if="busy" class="ac-skel">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!Object.keys(items).length && !hasMoreResults" class="ac-empty muted">
|
||||
Keine Treffer
|
||||
</div>
|
||||
<ul ref="resultsList" class="ac-list" role="listbox">
|
||||
<li
|
||||
v-for="(disp, id) in items"
|
||||
:key="id"
|
||||
:class="['ac-item', highlightedId === id ? 'is-active' : '']"
|
||||
@mousedown.prevent="choose(id, disp)"
|
||||
>
|
||||
<i class="fa-duotone fa-address-card"></i>
|
||||
<span class="txt">{{ disp }}</span>
|
||||
</li>
|
||||
<li v-if="hasMoreResults" class="ac-more-info muted">
|
||||
<i class="fa-duotone fa-ellipsis"></i>
|
||||
<span class="txt">Mehr Ergebnisse verfügbar</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-smart-autocomplete', TtSmartAutocomplete);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TtViewSwitcher - Tab-based view switcher (Vue 3)
|
||||
* Navigation component for switching between views
|
||||
*/
|
||||
|
||||
const TtViewSwitcher = {
|
||||
name: 'TtViewSwitcher',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
// Format: [{ id: 'view1', name: 'View 1', icon: 'fa-icon' }]
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { computed } = Vue;
|
||||
|
||||
const currentView = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:modelValue', val);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
currentView
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="tt-scope">
|
||||
<!-- Desktop tabs -->
|
||||
<nav class="view-tabs">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="tab-btn"
|
||||
:class="{active: currentView === option.id}"
|
||||
@click="currentView = option.id"
|
||||
>
|
||||
<i v-if="option.icon" :class="option.icon"></i>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile select -->
|
||||
<div class="view-select-wrap select">
|
||||
<select v-model="currentView">
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-view-switcher', TtViewSwitcher);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* TtDialog - Modern modal dialog (Vue 3)
|
||||
* Flexible dialog component with portal rendering
|
||||
*/
|
||||
|
||||
const TtDialog = {
|
||||
name: 'TtDialog',
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
modalClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal', // normal | wide | full
|
||||
validator: (value) => ['normal', 'wide', 'full'].includes(value)
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, computed, watch, nextTick, onBeforeUnmount } = Vue;
|
||||
|
||||
const el = ref(null);
|
||||
|
||||
const computedModalClass = computed(() => {
|
||||
const classes = [props.modalClass];
|
||||
if (props.size === 'wide') classes.push('modal-card-wide');
|
||||
if (props.size === 'full') classes.push('modal-card-full');
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
watch(() => props.show, (isShown) => {
|
||||
if (isShown) {
|
||||
nextTick(() => {
|
||||
// Move modal to body to prevent z-index issues
|
||||
if (el.value && el.value.nodeType === 1 && el.value.parentNode !== document.body) {
|
||||
document.body.appendChild(el.value);
|
||||
}
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.show && el.value && el.value.nodeType === 1 && el.value.parentNode === document.body) {
|
||||
document.body.removeChild(el.value);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
return {
|
||||
el,
|
||||
computedModalClass,
|
||||
handleClose
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
ref="el"
|
||||
class="tt-scope modal-overlay"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="modal-card pop" :class="computedModalClass">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title">
|
||||
<i class="fa-duotone fa-database"></i>
|
||||
{{ title }}
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
@click="handleClose"
|
||||
aria-label="Close"
|
||||
title="Schließen"
|
||||
>
|
||||
<i class="fa-duotone fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
`
|
||||
};
|
||||
|
||||
// Register component globally if Vue 3 app instance is available
|
||||
if (window.VueApp) {
|
||||
window.VueApp.component('tt-dialog', TtDialog);
|
||||
}
|
||||
Reference in New Issue
Block a user