250 lines
17 KiB
JavaScript
250 lines
17 KiB
JavaScript
/* ===== Radius.js ===== */
|
|
|
|
/* ---------- Shared Utilities (global) ---------- */
|
|
function loadScript(src) {
|
|
return new Promise((resolve, reject) => {
|
|
if (document.querySelector(`script[src="${src}"]`)) {
|
|
return resolve();
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.onload = resolve;
|
|
script.onerror = () => reject(new Error(`Script load error for ${src}`));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
async function copyToClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text || '');
|
|
return true;
|
|
} catch {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text || '';
|
|
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
document.body.appendChild(ta); ta.select();
|
|
try { document.execCommand('copy'); } catch {}
|
|
document.body.removeChild(ta);
|
|
return false;
|
|
}
|
|
}
|
|
function formatBytes(bytes, decimals = 2) {
|
|
bytes = parseInt(bytes, 10);
|
|
if (!bytes || bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
}
|
|
function formatDuration(seconds) {
|
|
if (!seconds || seconds < 0) return '0s';
|
|
seconds = parseInt(seconds, 10);
|
|
const d = Math.floor(seconds / (3600*24));
|
|
const h = Math.floor(seconds % (3600*24) / 3600);
|
|
const m = Math.floor(seconds % 3600 / 60);
|
|
if (d > 0) return `${d}t ${h}h`;
|
|
if (h > 0) return `${h}h ${m}m`;
|
|
if (m > 0) return `${m}m`;
|
|
return `< 1m`;
|
|
}
|
|
function calculateSimilarity(str1, str2) {
|
|
if (!str1 || !str2) return 0;
|
|
str1 = ('' + str1).toLowerCase();
|
|
str2 = ('' + str2).toLowerCase();
|
|
let match = 0;
|
|
for (let c of str1) if (str2.includes(c)) match++;
|
|
return (match / str1.length) * 100;
|
|
}
|
|
function validateData(strasse, plz, stadt, info) {
|
|
const thresholds = 90;
|
|
return !(
|
|
calculateSimilarity(strasse, info) < thresholds ||
|
|
calculateSimilarity(plz, info) < thresholds ||
|
|
calculateSimilarity(stadt, info) < thresholds
|
|
);
|
|
}
|
|
|
|
window.RadiusUtils = { calculateSimilarity, validateData, copyToClipboard, formatBytes, formatDuration, loadScript };
|
|
|
|
/* ---------- Reusable Component: radius-table-view ---------- */
|
|
Vue.component('radius-table-view', {
|
|
props: {
|
|
items: Array,
|
|
isLoading: Boolean,
|
|
hasSearched: Boolean,
|
|
density: { type: String, default: 'compact' },
|
|
tableClass: { type: String, default: '' },
|
|
tableStyle: Object,
|
|
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="table-view-wrapper">
|
|
<div v-if="!hasSearched" class="table-placeholder" :style="{minHeight: tableMinHeight}">
|
|
<i :class="initialPlaceholderIcon"></i>
|
|
<div>{{ initialPlaceholderText }}</div>
|
|
</div>
|
|
<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>
|
|
<div v-else-if="!items.length" class="table-placeholder" :style="{minHeight: tableMinHeight}">
|
|
<i :class="noResultsPlaceholderIcon"></i>
|
|
<div>{{ noResultsPlaceholderText }}</div>
|
|
</div>
|
|
<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>
|
|
`
|
|
});
|
|
|
|
/* ---------- Reusable Component: radius-file-drop ---------- */
|
|
Vue.component('radius-file-drop', {
|
|
data: () => ({ dragCounter: 0 }),
|
|
computed: { isDragging() { return this.dragCounter > 0; } },
|
|
template: `
|
|
<label class="file-drop" :class="{'is-dragover': isDragging}" @dragover.prevent @dragenter.prevent="dragCounter++" @dragleave.prevent="dragCounter--" @drop.prevent="onDrop">
|
|
<input type="file" accept=".xlsx" @change="$emit('file-selected', $event.target.files[0])" hidden ref="fileInput">
|
|
<div class="file-cta">
|
|
<i class="fa-duotone fa-cloud-arrow-up"></i>
|
|
<div>Hierhin ziehen oder <button type="button" class="link-btn" @click.prevent="$refs.fileInput.click()">Datei auswählen</button></div>
|
|
</div>
|
|
</label>
|
|
`,
|
|
methods: { onDrop(e) { this.dragCounter = 0; const file = e.dataTransfer.files?.[0]; if (file) this.$emit('file-selected', file); } }
|
|
});
|
|
|
|
/* ---------- Reusable Component: radius-processing-indicator ---------- */
|
|
Vue.component('radius-processing-indicator', {
|
|
props: ['progress', 'currentRow', 'totalRows', 'currentSerial'],
|
|
template: `
|
|
<div class="table-placeholder">
|
|
<i class="fa-duotone fa-hourglass-half animated-hourglass" style="font-size: 36px; margin-bottom: 10px; color: var(--brand-blue);"></i>
|
|
<div class="h5">Verarbeitung läuft...</div>
|
|
<slot name="description"><p v-if="currentSerial" class="muted small">Aktuell: {{ currentSerial || '—' }}</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 class="muted small mt-2">Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}</div>
|
|
</div>
|
|
`
|
|
});
|
|
|
|
/* ---------- Online state chip (fetches radacct when visible) ---------- */
|
|
Vue.component('radius-online-state', {
|
|
props: { username: String }, data: () => ({ data: null, observed: false, ob: null }),
|
|
template: `
|
|
<div class="radius-scope ros-wrap" ref="root">
|
|
<template v-if="data===null"><span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span></template>
|
|
<template v-else-if="data!==null"><span class="ros-chip" :class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]" :data-tooltip="data.ip ? 'IP-Adresse kopieren' : null" @click="copyIp($event)"><span class="dot"></span><span class="ip">{{ data.ip || '—' }}</span></span></template>
|
|
</div>
|
|
`,
|
|
mounted() { this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); if (this.$refs.root) this.ob.observe(this.$refs.root); },
|
|
beforeDestroy() { this.ob?.disconnect(); },
|
|
methods: {
|
|
async fetchState() { try { const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); this.data = r.ok ? await r.json() : { online: false, ip: null }; } catch { this.data = { online: false, ip: null }; } },
|
|
async copyIp(event) { if (!this.data?.ip) return; const c = event.currentTarget; if (!c || c.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(this.data.ip); c.classList.add('is-copied'); const o = c.dataset.tooltip; if (o) c.dataset.tooltip = 'Kopiert!'; setTimeout(() => { c.classList.remove('is-copied'); if (o) c.dataset.tooltip = o; }, 1500); }
|
|
}
|
|
});
|
|
|
|
/* ---------- Autocomplete ---------- */
|
|
Vue.component('radius-autocomplete', {
|
|
props: { value: String, placeholder: String, wide: { type: Boolean, default: true } }, data() { return { q: this.value || '', open: false, items: {}, highlighted: -1, busy: false, mode: 'autocomplete', logoDropdownOpen: false, hasMoreResults: false }; }, watch: { value(v){ if (v !== this.q) { this.q = v; if (this.mode === 'autocomplete') this.debouncedFetch(); } } },
|
|
template: `<div class="radius-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"><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 ref="mainInput" :placeholder="placeholderText" class="ri" v-model="q" autocomplete="off" autocapitalize="none" autocorrect="off" @input="onInput" @focus="mode === 'autocomplete' && maybeOpen()" @blur="deferClose"/><button v-if="q" class="btn-clear" @mousedown.prevent="clear" title="Feld leeren"><i class="fa-duotone fa-xmark"></i></button></div><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><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>`,
|
|
computed: { highlightedId(){ const k=Object.keys(this.items); return k[this.highlighted] || null; }, placeholderText() { return this.mode === 'autocomplete' ? (this.placeholder || 'Rechnungsadresse suchen') : 'Partner-Kundennummer eingeben'; } },
|
|
created() { this.debouncedFetch = this.debounce(() => this.fetchItems(), 220); },
|
|
methods: {
|
|
toggleLogoDropdown() { this.logoDropdownOpen = !this.logoDropdownOpen; if (this.logoDropdownOpen) this.open = false; },
|
|
selectMode(m) { if (this.mode !== m) { this.mode = m; this.$emit('mode-change', m); this.clear(); } this.logoDropdownOpen = false; this.$nextTick(() => this.$refs.mainInput.focus()); },
|
|
onInput() { this.$emit('input', this.q); if (this.mode === 'autocomplete') this.debouncedFetch(); },
|
|
onEnter() { if (this.mode === 'autocomplete') this.chooseHighlighted(true); else this.$emit('enter'); },
|
|
maybeOpen(){ this.open = true; if (this.q) this.debouncedFetch(); },
|
|
deferClose(){ setTimeout(()=> { this.open = false; this.logoDropdownOpen = false; }, 150); },
|
|
clear(){ this.q = ''; this.items={}; this.highlighted=-1; this.emitSelection('', ''); if (this.mode === 'autocomplete') { this.open = true; this.debouncedFetch(); } },
|
|
move(d){ const k=Object.keys(this.items); if (!k.length) return; this.highlighted=(this.highlighted+d+k.length)%k.length; this.$nextTick(() => { const a = this.$refs.resultsList?.querySelector('.is-active'); if (a) a.scrollIntoView({ block: 'center', behavior: 'smooth' }); }); },
|
|
chooseHighlighted(e){ const i=this.highlightedId; if (i) this.choose(i, this.items[i], e); else if (e) this.$emit('enter'); },
|
|
choose(id, display, emitEnter){ const c=(display.match(/\[(\d+)\]/)||[])[1] || ''; this.emitSelection(c, display); this.open=false; if (emitEnter) this.$emit('enter'); },
|
|
emitSelection(custnum, display){ this.$emit('select', { custnum, display }); this.$emit('input', display); this.$emit('change', display); },
|
|
async fetchItems() { if (this.mode !== 'autocomplete' || !this.q || this.q.length < 2) { this.items = {}; this.hasMoreResults = false; return; } this.busy = true; try { const b = window.TT_CONFIG.BASE_PATH || ''; const r = await fetch(`${b}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(this.q)}`); if (r.ok) { const j = await r.json(); const addresses = j?.result?.addresses || {}; if (addresses.more) { this.hasMoreResults = true; delete addresses.more; } else { this.hasMoreResults = false; } this.items = addresses; this.highlighted = 0; } else { this.items = {}; this.hasMoreResults = false; } } catch { this.items = {}; this.hasMoreResults = false; } this.busy = false; },
|
|
debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
|
|
}
|
|
});
|
|
|
|
/* ---------- Generic Modal ---------- */
|
|
Vue.component('radius-modal', {
|
|
props: { show: Boolean, title: String, modalClass: String },
|
|
template: `
|
|
<transition name="fade">
|
|
<div v-if="show" class="radius-scope modal-overlay" @click.self="$emit('close')">
|
|
<div class="modal-card pop" :class="modalClass">
|
|
<div class="modal-head">
|
|
<div class="modal-title"><i class="fa-duotone fa-database"></i> {{ title }}</div>
|
|
<button class="icon-btn" @click="$emit('close')" aria-label="Close" title="Schließen"><i class="fa-duotone fa-xmark"></i></button>
|
|
</div>
|
|
<div class="modal-body"><slot/></div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
`,
|
|
watch: {
|
|
show(isShown) {
|
|
if (isShown) {
|
|
this.$nextTick(() => {
|
|
// nodeType 1 is an Element node, this prevents errors if v-if renders a comment node.
|
|
if (this.$el && this.$el.nodeType === 1 && this.$el.parentNode !== document.body) {
|
|
document.body.appendChild(this.$el);
|
|
}
|
|
document.body.style.overflow = 'hidden';
|
|
});
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
}
|
|
},
|
|
beforeDestroy() {
|
|
if (this.show && this.$el && this.$el.nodeType === 1 && this.$el.parentNode === document.body) {
|
|
document.body.removeChild(this.$el);
|
|
}
|
|
document.body.style.overflow = '';
|
|
}
|
|
});
|
|
|
|
|
|
/* ---------- Root View: <radius> ---------- */
|
|
Vue.component('radius', {
|
|
template: `
|
|
<div class="radius-scope radius-container">
|
|
<section class="card card-in">
|
|
<div class="pane-header"><div class="title"><span class="logo-dot"></span><span>Radius</span></div><nav class="view-tabs"><button v-for="i in viewOptions" :key="i.id" class="tab-btn" :class="{active:view===i.id}" @click="switchView(i.id)"><i :class="i.icon"></i> {{ i.name }}</button></nav><div class="view-select-wrap select"><select v-model="view" @change="switchView($event.target.value)"><option v-for="i in viewOptions" :value="i.id">{{ i.name }}</option></select></div></div>
|
|
<hr class="content-divider" />
|
|
<section v-show="view==='users'" class="card-in"><radius-users/></section><section v-show="view==='free'" class="card-in"><radius-free-users ref="freeView"/></section><section v-show="view==='unused'" class="card-in"><radius-unused-users ref="unusedView"/></section><section v-show="view==='ont'" class="card-in"><radius-ont-parser/></section><section v-show="view==='ontReverse'" class="card-in"><radius-ont-finder/></section>
|
|
</section>
|
|
</div>
|
|
`,
|
|
data() { return { view: 'users', window: window, _initFlags: {} }; },
|
|
computed: {
|
|
viewOptions() {
|
|
const o = [{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },{ id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }];
|
|
if (window.TT_CONFIG.CAN_BILLING === '1') { o.push({ id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' }, { id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' }); } return o;
|
|
}
|
|
},
|
|
mounted() { this.switchView(this.view); },
|
|
methods: {
|
|
switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}}
|
|
}
|
|
}); |