361 lines
20 KiB
JavaScript
361 lines
20 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,
|
|
isHovering: false,
|
|
ctrlPressed: false,
|
|
tooltipText: 'IP-Adresse kopieren'
|
|
}),
|
|
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="tooltipText"
|
|
@click="onClickIp"
|
|
@mouseover="onIpMouseOver"
|
|
@mouseout="onIpMouseOut"
|
|
>
|
|
<span class="dot"></span>
|
|
<span class="ip">{{ data.ip || '—' }}</span>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
`,
|
|
watch: {
|
|
data(newData) {
|
|
// Update tooltip text when data is loaded
|
|
if (newData && newData.ip) {
|
|
this.tooltipText = 'IP-Adresse kopieren';
|
|
} else {
|
|
this.tooltipText = null;
|
|
}
|
|
}
|
|
},
|
|
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);
|
|
// Listen for Ctrl/Meta key presses globally
|
|
document.addEventListener('keydown', this.handleKey);
|
|
document.addEventListener('keyup', this.handleKey);
|
|
},
|
|
beforeDestroy() {
|
|
this.ob?.disconnect();
|
|
// Clean up global listeners
|
|
document.removeEventListener('keydown', this.handleKey);
|
|
document.removeEventListener('keyup', this.handleKey);
|
|
},
|
|
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');
|
|
|
|
// Temporarily change tooltip to "Kopiert!"
|
|
const originalTooltip = this.tooltipText;
|
|
this.tooltipText = 'Kopiert!';
|
|
|
|
setTimeout(() => {
|
|
c.classList.remove('is-copied');
|
|
// Restore original tooltip
|
|
this.tooltipText = originalTooltip;
|
|
// Re-run updateTooltip in case Ctrl is still pressed
|
|
this.updateTooltip();
|
|
}, 1500);
|
|
},
|
|
// --- New methods for Ctrl+Click ---
|
|
handleKey(event) {
|
|
const newCtrlPressed = event.ctrlKey || event.metaKey;
|
|
if (newCtrlPressed !== this.ctrlPressed) {
|
|
this.ctrlPressed = newCtrlPressed;
|
|
// If hovering, update tooltip live
|
|
if (this.isHovering) {
|
|
this.updateTooltip();
|
|
}
|
|
}
|
|
},
|
|
onIpMouseOver(event) {
|
|
this.isHovering = true;
|
|
this.ctrlPressed = event.ctrlKey || event.metaKey;
|
|
this.updateTooltip();
|
|
},
|
|
onIpMouseOut() {
|
|
this.isHovering = false;
|
|
this.ctrlPressed = false; // Reset on mouse out
|
|
this.updateTooltip();
|
|
},
|
|
updateTooltip() {
|
|
if (!this.data?.ip) {
|
|
this.tooltipText = null;
|
|
} else if (this.isHovering && this.ctrlPressed) {
|
|
this.tooltipText = 'Scan starten & verbinden';
|
|
} else {
|
|
this.tooltipText = 'IP-Adresse kopieren';
|
|
}
|
|
},
|
|
onClickIp(event) {
|
|
if (!this.data?.ip) return;
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
// Ctrl+Click or Meta+Click
|
|
event.preventDefault();
|
|
this.$emit('scan-ip', { ip: this.data.ip });
|
|
} else {
|
|
// Normal click
|
|
this.copyIp(event);
|
|
}
|
|
}
|
|
// --- End new methods ---
|
|
}
|
|
});
|
|
|
|
/* ---------- 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;}});}}
|
|
}
|
|
});
|