diff --git a/Layout/default/AddressDB/View.php b/Layout/default/AddressDB/View.php index e3490844c..33814c0ea 100644 --- a/Layout/default/AddressDB/View.php +++ b/Layout/default/AddressDB/View.php @@ -180,7 +180,16 @@ wohneinheiten as $unit): ?> - + contact ? json_decode($unit->contact, true) : []; + $contactCount = is_array($contacts) ? count($contacts) : 0; + ?> + + + + + + $unit->id])?>"> id?> diff --git a/Layout/default/ConstructionConsentProject/Form.php b/Layout/default/ConstructionConsentProject/Form.php index 2dc324781..2acce5f25 100644 --- a/Layout/default/ConstructionConsentProject/Form.php +++ b/Layout/default/ConstructionConsentProject/Form.php @@ -1,5 +1,6 @@ - + +
@@ -27,7 +28,7 @@
"> - "/> + "/>
@@ -36,21 +37,21 @@
- + " />
- + " />
- + " />
@@ -58,8 +59,9 @@
@@ -70,21 +72,21 @@
- + " />
- + " />
- + " />
@@ -96,8 +98,9 @@
@@ -108,7 +111,7 @@
- +
diff --git a/Layout/default/Network/Form.php b/Layout/default/Network/Form.php index 55051113a..9a0c1d385 100644 --- a/Layout/default/Network/Form.php +++ b/Layout/default/Network/Form.php @@ -1,4 +1,6 @@ + +
@@ -8,7 +10,7 @@

Netzgebiete

@@ -22,54 +24,54 @@
-

id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?>

- +

id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?>

+ ">
- - - + + " /> +
- + ">
- +
- +
- +
- +
@@ -81,22 +83,22 @@
- +
- +
- +
diff --git a/Layout/default/Order/Index.php b/Layout/default/Order/Index.php index d04ec342e..a84304f42 100644 --- a/Layout/default/Order/Index.php +++ b/Layout/default/Order/Index.php @@ -1,9 +1,13 @@ getUrl($Mod,"Index"); $pagination_baseurl_params = ["filter" => $filter]; $pagination_entity_name = "Bestellungen"; - //var_dump($mynetworks); $sorted_networks = []; if(is_array($mynetworks) && count($mynetworks)) { @@ -63,7 +67,7 @@ sections) && count($fnet->sections)): ?> sections as $section): ?> - + @@ -75,55 +79,55 @@
- +
- +
- +
- +
- +
- +
- +
- +
- - + +
- +
- +
@@ -245,7 +249,7 @@ $cpe_config_finished = true; } } - if($hw && $voip_chan && $patched && $cpe_config_finished) { + if($hw && $voip && $patched && $cpe_config_finished) { break; } } @@ -697,7 +701,7 @@ $cpe_config_finished = true; } } - if($hw && $voip_chan && $patched && $cpe_config_finished) { + if($hw && $voip && $patched && $cpe_config_finished) { break; } } diff --git a/Layout/default/Pipework/Index.php b/Layout/default/Pipework/Index.php index f9c463ec6..a87ca5d26 100644 --- a/Layout/default/Pipework/Index.php +++ b/Layout/default/Pipework/Index.php @@ -47,7 +47,7 @@
@@ -60,7 +60,7 @@ sections) && count($fnet->sections)): ?> sections as $section): ?> - + @@ -102,12 +102,17 @@
- +
- + +
+ +
+ +
diff --git a/Layout/default/Pop/Detail.php b/Layout/default/Pop/Detail.php index de8e24d67..7e498970e 100644 --- a/Layout/default/Pop/Detail.php +++ b/Layout/default/Pop/Detail.php @@ -3,7 +3,7 @@ type="text/css"/> - +
@@ -66,6 +66,10 @@ if (!empty(trim($pops->vlan_ipv6))) Name name ?> + + Kategorie + category]['name']." (".$categoryArray[$pops->category]['comment'].")" ?> + Standort @@ -614,11 +618,11 @@ if (!empty(trim($pops->vlan_ipv6)))
- +
+ +
+ +
+
diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index 10367c511..aa3488ba6 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -1083,7 +1083,16 @@ $pagination_entity_name = "Vorbestellungen";
is(["preorderfront"]) && !$me->is("preorderreadonly")): ?> - + adb_wohneinheit_id && $preorder->adb_wohneinheit && $preorder->adb_wohneinheit->contact) ? json_decode($preorder->adb_wohneinheit->contact, true) : []; + $contactCount = is_array($contacts) ? count($contacts) : 0; + ?> + + + + + + $preorder->id])?>"> isAdmin()): ?> diff --git a/Layout/default/Preorder/export.csv.php b/Layout/default/Preorder/export.csv.php index 0658bd761..e39f34bab 100644 --- a/Layout/default/Preorder/export.csv.php +++ b/Layout/default/Preorder/export.csv.php @@ -75,8 +75,8 @@ while($data = mysqli_fetch_object($res)): if($data->attributes) { $attribs = json_decode($data->attributes, true); - if($attribs['bep_specified']) $bep = true; - if($attribs['inhouse_cabling_supplied']) $inhouse = true; + if(isset($attribs['bep_specified']) && $attribs['bep_specified']) $bep = true; + if(isset($attribs['inhouse_cabling_supplied']) && $attribs['inhouse_cabling_supplied']) $inhouse = true; } $addon_property = 0; diff --git a/Layout/default/Preordercampaign/Form.php b/Layout/default/Preordercampaign/Form.php index 714d766de..2ad1f965c 100644 --- a/Layout/default/Preordercampaign/Form.php +++ b/Layout/default/Preordercampaign/Form.php @@ -1,4 +1,6 @@ + +
@@ -28,7 +30,7 @@ "> - + "/>
@@ -39,7 +41,7 @@
@@ -49,7 +51,7 @@
+ value="name : "" ?>"/>
@@ -57,7 +59,7 @@
+ name="description">description : "" ?>
@@ -65,7 +67,7 @@
+ value="area : "" ?>"/>
@@ -73,7 +75,7 @@
+ value="homes_total : "" ?>"/>
@@ -81,7 +83,7 @@
"/> + value="from) ? date('d.m.Y', $campaign->from) : "" ?>"/>
@@ -89,7 +91,7 @@
"/> + value="to) ? date('d.m.Y', $campaign->to) : "" ?>"/>
@@ -100,30 +102,31 @@
+ types)) ? $campaign->types : []; ?>
@@ -134,16 +137,16 @@
@@ -155,13 +158,13 @@
@@ -171,6 +174,10 @@
+ salesclusters)) ? $campaign->salesclusters : []; ?> + all_fcp_names)) ? $campaign->all_fcp_names : []; ?> + banned_fcps)) ? $campaign->banned_fcps : []; ?> + required_fields)) ? $campaign->required_fields : []; ?>
@@ -182,7 +189,7 @@ name="adb_netzgebiet_ids[]" id="adb_netzgebiet_ids" multiple="multiple" data-placeholder="Salescluster ..."> - +
@@ -195,8 +202,8 @@
@@ -208,7 +215,7 @@
@@ -221,10 +228,10 @@ Ort:
@@ -238,10 +245,10 @@
@@ -253,10 +260,10 @@ pro Wohneinheit (API):
@@ -270,7 +277,7 @@
+ value="cifurl : "" ?>"/> Customer Installation Feedback (für QR-Code bei Status 145).
Templatevariable {{CIFTOKEN}} wird mit echtem Cif Token ersetzt
@@ -284,7 +291,7 @@ for="cifcableurl">Kabelnachbestell-Url
+ value="cifcableurl : "" ?>"/> Für Begleitschreiben - Status 145
@@ -335,13 +342,15 @@
+ active_operators)) ? $campaign->active_operators : []; ?> + passive_operators)) ? $campaign->passive_operators : []; ?>

Netzbetreiber

Aktivnetzbetreiber

- active_operators as $aop): ?> +
@@ -415,7 +424,7 @@ id="passive_operators" multiple="multiple" data-placeholder="Netzbetreiber wählen ..."> ["netowner", "salespartner"]]) as $operator): ?> - +
@@ -433,7 +442,7 @@
- + " />
@@ -611,8 +620,9 @@ + name="corsorigins">corsorigins) ? implode("\n", $campaign->corsorigins) : "" ?> Hostname der Website, mit oder ohne Protokoll (https://); *. als Wildcard erlaubt (*.domain.com); ein Eintrag pro Zeile @@ -642,7 +652,7 @@
+ id="note">note : "" ?>
@@ -754,8 +764,8 @@ + + + + + diff --git a/Layout/default/Workflow/items/color.php b/Layout/default/Workflow/items/color.php index 094803bf5..619de4a03 100644 --- a/Layout/default/Workflow/items/color.php +++ b/Layout/default/Workflow/items/color.php @@ -11,7 +11,7 @@ if(preg_match('/^(.+)-1R$/', $color_name, $cmatch)) { +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Name / ExtRefQuelleFreigabenNetzwerkKampagneZustimmung
+ {{ item.netzgebiet.name || '(Ohne Name)' }} +
{{ item.netzgebiet.extref }}
+
+ {{ item.netzgebiet.source || '—' }} +
{{ item.netzgebiet.source_id }}
+
+
+ {{ f.charAt(0).toUpperCase() }} + +
+
+ + + Erstellen + + + + + Erstellen + + + + + +
+ + +
+ + Lade Netzgebiete... +
+ + +
+ + Keine Netzgebiete gefunden. +
+
+ + +
+
+ {{ paginationStart }}–{{ paginationEnd }} von {{ filteredNetzgebiete.length }} Netzgebieten +
+
+ + + {{ currentPage }} / {{ totalPages }} + +
+
+ + + + + + + + + + + +
+
+ +
+
+ + Kein Verlauf vorhanden. +
+
+
+
+ + + +
+
+
+ {{ translateAction(entry.action) }} + {{ translateField(entry.field) }} + {{ entry.user_name || 'System' }} · {{ formatTimestamp(entry.timestamp) }} +
+
+ {{ formatValue(entry.field, entry.old_value) }} + + {{ formatValue(entry.field, entry.new_value) }} +
+
+
+
+
+
+
+ `, + + data() { + return { + window: window, + isLoading: true, + isSaving: false, + netzgebiete: [], + currentPage: 1, + pageSize: 50, + filters: { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' }, + filterDebounce: null, + showEditModal: false, + editItem: null, + showHistoryModal: false, + historyLoading: false, + historyItems: [], + historyTitle: 'Verlauf', + expandedIds: {}, + freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' }, + freigabeOptions: [ + { key: 'interest', label: 'Interest' }, + { key: 'provision', label: 'Provision' }, + { key: 'order', label: 'Order' }, + { key: 'reorder', label: 'Reorder' } + ], + optionsConfig: [ + { key: 'create_address_parts', label: 'create_address_parts', tooltip: 'Neue Straßen/PLZ/Ort anlegen' }, + { key: 'update_freigabe', label: 'update_freigabe', tooltip: 'Setzt Freigabe auf Basis Netzgebiet' }, + { key: 'update_address', label: 'update_address', tooltip: 'Straßennamen ändern' }, + { key: 'hausnummer_dont_overwrite_netzgebiet', label: 'dont_overwrite_netzgebiet', tooltip: 'Netzgebiete nicht überschreiben' }, + { key: 'create_preorder', label: 'create_preorder', tooltip: 'Bestellungen erstellen (SBIDI)' }, + { key: 'preorder_only_oaid', label: 'preorder_only_oaid', tooltip: 'SBIDI OAID aus RIMO' }, + { key: 'wo_ignore_status', label: 'wo_ignore_status', tooltip: 'Status ignorieren' }, + { key: 'delete_units', label: 'delete_units', tooltip: 'Homes löschen die nicht in RIMO sind' }, + { key: 'unit_create_oaid', label: 'unit_create_oaid', tooltip: 'OAID bei Unit erstellen' } + ], + defaultOptions: { + create_address_parts: 0, update_freigabe: 1, update_address: 1, + hausnummer_dont_overwrite_netzgebiet: 0, create_preorder: 0, + preorder_only_oaid: 0, wo_ignore_status: 0, delete_units: 0, + mph_min_homes_tool_automatic_count: 3, unit_create_oaid: 0 + } + }; + }, + + computed: { + availableSources() { + const sources = new Set(); + this.netzgebiete.forEach(item => { + if (item.netzgebiet?.source) sources.add(item.netzgebiet.source); + }); + return Array.from(sources).sort(); + }, + hasActiveFilters() { + return Object.values(this.filters).some(v => v); + }, + filteredNetzgebiete() { + return this.netzgebiete.filter(item => { + const n = item.netzgebiet; + if (!n) return false; + if (this.filters.name && !n.name?.toLowerCase().includes(this.filters.name.toLowerCase())) return false; + if (this.filters.extref && !n.extref?.toLowerCase().includes(this.filters.extref.toLowerCase())) return false; + if (this.filters.source && n.source !== this.filters.source) return false; + const hasNetwork = item.related?.networks?.length > 0; + const hasCampaign = item.related?.campaigns?.length > 0; + const hasConsent = item.related?.consent_projects?.length > 0; + if (this.filters.hasNetwork === 'yes' && !hasNetwork) return false; + if (this.filters.hasNetwork === 'no' && hasNetwork) return false; + if (this.filters.hasCampaign === 'yes' && !hasCampaign) return false; + if (this.filters.hasCampaign === 'no' && hasCampaign) return false; + if (this.filters.hasConsent === 'yes' && !hasConsent) return false; + if (this.filters.hasConsent === 'no' && hasConsent) return false; + return true; + }); + }, + totalPages() { return Math.ceil(this.filteredNetzgebiete.length / this.pageSize) || 1; }, + paginatedItems() { + const start = (this.currentPage - 1) * this.pageSize; + return this.filteredNetzgebiete.slice(start, start + this.pageSize); + }, + paginationStart() { return this.filteredNetzgebiete.length ? (this.currentPage - 1) * this.pageSize + 1 : 0; }, + paginationEnd() { return Math.min(this.currentPage * this.pageSize, this.filteredNetzgebiete.length); }, + filteredHistory() { + return this.historyItems.filter(e => !['edit', 'create'].includes(e.field)); + } + }, + + watch: { + filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; } + }, + + async mounted() { await this.fetchNetzgebiete(); }, + + methods: { + debouncedFilter() { + clearTimeout(this.filterDebounce); + this.filterDebounce = setTimeout(() => this.currentPage = 1, 300); + }, + applyFilter() { this.currentPage = 1; }, + clearFilters() { + this.filters = { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' }; + this.currentPage = 1; + }, + async fetchNetzgebiete() { + this.isLoading = true; + try { + const response = await axios.get(window.TT_CONFIG.GET_URL); + this.netzgebiete = response.data.success ? (response.data.data || []) : (response.data || []); + } catch (error) { + console.error('Fehler:', error); + window.notify?.('error', 'Netzgebiete konnten nicht geladen werden.'); + } finally { + this.isLoading = false; + } + }, + parsedFreigabe(json) { + try { return JSON.parse(json || '[]') || []; } + catch { return []; } + }, + openCreateModal() { + this.editItem = { + id: null, name: '', extref: '', source: '', source_id: '', + freigabe: { interest: true, provision: true, order: true, reorder: true }, + options: { ...this.defaultOptions } + }; + this.showEditModal = true; + }, + openEditModal(item) { + const n = item.netzgebiet; + let options = {}; + try { options = JSON.parse(n.options || '{}'); } catch {} + let freigabeArr = []; + try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {} + const freigabeObj = {}; + ['interest', 'provision', 'order', 'reorder'].forEach(f => freigabeObj[f] = freigabeArr.includes(f)); + this.editItem = { + id: n.id, name: n.name || '', extref: n.extref || '', + source: n.source || '', source_id: n.source_id || '', + freigabe: freigabeObj, + options: { ...this.defaultOptions, ...options } + }; + this.showEditModal = true; + }, + async saveNetzgebiet() { + if (!this.editItem?.name) return; + this.isSaving = true; + const freigabeArray = Object.keys(this.editItem.freigabe).filter(k => this.editItem.freigabe[k]); + const payload = { + id: this.editItem.id, name: this.editItem.name, extref: this.editItem.extref, + source: this.editItem.source, source_id: this.editItem.source_id, + freigabe: freigabeArray, options: this.editItem.options + }; + try { + const response = await axios.post(window.TT_CONFIG.SAVE_URL, payload); + if (response.data.success) { + window.notify?.('success', response.data.message); + this.showEditModal = false; + await this.fetchNetzgebiete(); + } else { + window.notify?.('error', response.data.message || 'Fehler beim Speichern.'); + } + } catch { window.notify?.('error', 'Netzwerkfehler.'); } + finally { this.isSaving = false; } + }, + async openHistoryModal(item) { + this.historyTitle = `Verlauf: ${item.netzgebiet.name}`; + this.showHistoryModal = true; + this.historyLoading = true; + this.historyItems = []; + try { + const response = await axios.get(window.TT_CONFIG.HISTORY_URL + '?id=' + item.netzgebiet.id); + this.historyItems = response.data.success ? (response.data.data || []) : (response.data || []); + } catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); } + finally { this.historyLoading = false; } + }, + translateAction(action) { return { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht' }[action] || action; }, + translateField(field) { + return { name: 'Name', extref: 'ExtRef', source: 'Quelle', source_id: 'Source ID', + freigabe: 'Freigaben', options: 'Optionen', unit_counts: 'Einheiten' }[field] || field; + }, + formatTimestamp(ts) { + if (!ts) return '—'; + try { return new Date(ts.replace(' ', 'T')).toLocaleString('de-AT'); } + catch { return ts; } + }, + formatValue(field, value) { + if (value === null || value === undefined || value === '') return '—'; + if (['freigabe', 'options'].includes(field)) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (field === 'freigabe' && Array.isArray(parsed)) return parsed.join(', ') || '—'; + if (field === 'options' && typeof parsed === 'object') { + const entries = Object.entries(parsed).filter(([,v]) => v !== 0 && v !== '0'); + return entries.map(([k,v]) => `${k}: ${v}`).join(', ') || '—'; + } + } catch {} + } + return String(value); + }, + isLongValue(field, value) { + return this.formatValue(field, value).length > 40; + }, + toggleExpand(id) { + this.expandedIds[id] = !this.expandedIds[id]; + } + } +}; + +if (window.VueApp) { + window.VueApp.component('a-d-b-netzgebiet', ADBNetzgebiet); +} diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.css b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css index 681de24eb..0819cc982 100644 --- a/public/js/pages/Cpeprovisioning/Cpeprovisioning.css +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css @@ -8,24 +8,35 @@ body { padding-bottom: 2rem; } +.cpe-provisioning-page .form-group { + margin-bottom: 0; +} + .cpe-provisioning-page .filter-wrapper { background: #fff; - padding: 1rem; + padding: 0.75rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1.5rem; } +.cpe-provisioning-page .filter-wrapper .form-control, +.cpe-provisioning-page .filter-wrapper .custom-select { + height: 31px; +} + .cpe-provisioning-page .filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; + gap: 0.75rem; align-items: end; } .cpe-provisioning-page .filter-actions { display: flex; gap: 0.5rem; + align-items: flex-end; + padding-bottom: 2px; } .loading-indicator, .no-results-indicator { diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js index f81021b9c..4ce7a72cd 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.js +++ b/public/js/pages/ManualInvoice/ManualInvoice.js @@ -16,12 +16,29 @@ Vue.component('manual-invoice', { - + `, - data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }), + data: () => ({ isModalOpen: false, editingInvoiceData: null, shippingNoteImportData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }), + mounted() { + // Check for shipping note import data + const shippingNoteData = localStorage.getItem('ManualInvoice_create'); + if (shippingNoteData) { + try { + // Parse and store the data + this.shippingNoteImportData = JSON.parse(shippingNoteData); + // Delete from localStorage immediately so it doesn't auto-open again on reload + localStorage.removeItem('ManualInvoice_create'); + // Auto-open modal for import + this.openModal(); + } catch (e) { + console.error('Error parsing shipping note data:', e); + localStorage.removeItem('ManualInvoice_create'); + } + } + }, methods: { openModal(invoice = null) { this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null; @@ -30,6 +47,7 @@ Vue.component('manual-invoice', { closeModal() { this.isModalOpen = false; this.editingInvoiceData = null; + this.shippingNoteImportData = null; this.$refs.table.$refs.table.refreshTable(); }, async handleSave(invoiceData) { @@ -126,7 +144,7 @@ Vue.component('manual-invoice', { }); Vue.component('manual-invoice-modal', { - props: ['initialData'], + props: ['initialData', 'shippingNoteImport'], template: `
Drücke STRG + Q um die Vorschau umzuschalten.
@@ -278,6 +296,16 @@ Vue.component('manual-invoice-modal', { } if (!Array.isArray(this.invoiceData.positions)) this.invoiceData.positions = []; } + + // Check for shipping note import data from prop + if (this.shippingNoteImport && Array.isArray(this.shippingNoteImport) && this.shippingNoteImport.length > 0) { + try { + this.processShippingNoteImport(this.shippingNoteImport); + } catch (e) { + console.error('Error processing shipping note import:', e); + window.notify('error', 'Fehler beim Importieren des Lieferscheins'); + } + } }, mounted() { window.addEventListener('resize', this.handleResize); @@ -334,6 +362,87 @@ Vue.component('manual-invoice-modal', { } finally { this.pdfLoading = false; } + }, + processShippingNoteImport(shippingNoteDataArray) { + // Temporarily disable the preview update during import to prevent memory leak + clearTimeout(this.previewDebounceTimer); + const originalWatcher = this.$options.watch['invoiceData']; + delete this.$options.watch['invoiceData']; + + try { + for (const shippingNoteData of shippingNoteDataArray) { + // Pre-fill billing address fields + if (shippingNoteData.billingAddress) { + const addr = shippingNoteData.billingAddress; + + Object.assign(this.invoiceData, { + billingaddress_id: addr.id, + customer_number: addr.customer_number || 0, + company: addr.company || '', + firstname: addr.firstname || '', + lastname: addr.lastname || '', + street: addr.street || '', + zip: addr.zip || '', + city: addr.city || '', + email: addr.email || '', + uid: addr.uid || '', + fibu_account_number: addr.fibu_account_number || 0, + fibu_payment_due: addr.fibu_payment_due || 14, + fibu_payment_skonto: addr.fibu_payment_skonto || 0, + fibu_payment_skonto_rate: addr.fibu_payment_skonto_rate || 0, + billing_type: addr.billing_type || 'invoice', + owner_id: addr.id + }); + + // Banking info (if SEPA) + if (addr.billing_type === 'sepa') { + Object.assign(this.invoiceData, { + bank_account_bank: addr.bank_account_bank || '', + bank_account_owner: addr.bank_account_owner || '', + bank_account_iban: addr.bank_account_iban || '', + bank_account_bic: addr.bank_account_bic || '', + sepa_date: addr.sepa_date || '' + }); + } + } + + // Pre-fill external reference with shipping note reference + this.invoiceData.externe_referenz = `Lieferschein #${shippingNoteData.shippingNoteId}`; + + // Add introductory text if shipping note has notes + if (shippingNoteData.note) { + this.invoiceData.einleitender_text = shippingNoteData.note; + } + + // Add all positions (batch operation to avoid triggering watcher for each item) + if (shippingNoteData.positions && Array.isArray(shippingNoteData.positions)) { + const newPositions = shippingNoteData.positions.map(position => ({ + product_name: position.product_name || '', + product_info: position.product_info || '', + amount: parseFloat(position.amount) || 0, + unit: position.unit || 'Stk.', + price: parseFloat(position.price) || 0, + discount: parseFloat(position.discount) || 0, + vatrate: parseFloat(position.vatrate) || 20 + })); + + // Add all positions at once instead of one by one + this.invoiceData.positions.push(...newPositions); + } + } + + // Notify user + const positionCount = shippingNoteDataArray.reduce((sum, sn) => sum + (sn.positions?.length || 0), 0); + window.notify('success', `Lieferschein erfolgreich importiert (${positionCount} Position(en))`); + } finally { + // Re-enable the watcher + this.$options.watch['invoiceData'] = originalWatcher; + + // Trigger one preview update after import is complete + this.$nextTick(() => { + this.debouncedPreviewUpdate(); + }); + } } } }); diff --git a/public/js/pages/Pop/Pop.js b/public/js/pages/Pop/Pop.js index 7746c1f15..d4b337192 100644 --- a/public/js/pages/Pop/Pop.js +++ b/public/js/pages/Pop/Pop.js @@ -1,5 +1,6 @@ Vue.component('Pop', { //language=Vue + // g template: ` @@ -37,7 +38,7 @@ Vue.component('Pop', { @@ -55,6 +56,11 @@ Vue.component('Pop', { defaultPageSize: 25, headers: [ {text: 'Name', key: 'name', priority: 10}, + {text: 'Kategorie', key: 'category', class: 'text-center', priority: 4, filter: 'select', filterOptions: [ + {value: '1', text: 'Outdoor (Kasten/Schrank)'}, + {value: '2', text: 'Indoor (Keller Gebäude)'}, + {value: '3', text: 'Sender/Funk (Sendemast)'}, + {value: '4', text: 'Container (Garage, Container)'}]}, {text: 'Netzgebiet', key: 'networkArea', class: 'text-center', // TODO: fix autocomplete Filter // filter: 'autocomplete', diff --git a/public/js/pages/Radius/Radius.css b/public/js/pages/Radius/Radius.css index 5fc4970fa..d5f837d92 100644 --- a/public/js/pages/Radius/Radius.css +++ b/public/js/pages/Radius/Radius.css @@ -1,249 +1,246 @@ -/* ===== Radius.css ===== */ -:root{ --brand-blue: #005384; --bg: #ffffff; --card: #ffffff; --card-2: #f8fafc; --muted: #667085; --text: #0b1320; --accent: var(--brand-blue); --accent-2: #1e88c9; --ok: #0f9d58; --bad: #e03131; --ring: rgba(0,83,132,.20); --border: #e6e9ef; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --radius: 10px; --radius-pill: 999px; --shadow: 0 8px 24px rgba(0, 83, 132, .08); } -.radius-scope a.link { color: var(--accent); text-decoration: none; font-weight: 500; transition: color .2s ease; } -.radius-scope a.link:hover { color: var(--accent-2); text-decoration: underline; } -.radius-scope .muted { color: var(--muted); } -.radius-scope .small { font-size: 12px; } -.radius-scope .mini { font-size: 11px; } -.radius-scope .mono { font-family: var(--mono); } -.radius-scope .center { text-align: center; } -.radius-scope .p-sm { padding: .5rem; } -.radius-scope .p-lg { padding: 1.25rem; } -.radius-scope .mt-2 { margin-top: .5rem; } -.radius-scope .mt-3 { margin-top: .75rem; } -.radius-scope .mt-between { margin-top: 12px; } -.radius-scope .nowrap { white-space: nowrap; } -.radius-scope .inline-copy { display:flex; align-items:center; gap:8px; justify-content: flex-end; } -.radius-scope .grid { display:grid; } -.radius-scope .g-2 { gap: 8px; } -.radius-scope .g-3 { gap: 12px; } -.radius-scope .g-4 { gap: 16px; } -.radius-scope .g-6 { gap: 24px; } -.radius-scope .cols-1 { grid-template-columns: 1fr; } -.radius-scope .cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } -.radius-scope .cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } -.radius-scope .cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); } -@media (max-width: 900px){ .radius-scope .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } } -@media (max-width: 600px){ .radius-scope .cols-4 { grid-template-columns: 1fr; } } -@media (min-width: 900px){ .radius-scope .cols-2@lg { grid-template-columns: repeat(2, minmax(0,1fr)); } .radius-scope .cols-4@lg { grid-template-columns: repeat(4, minmax(0,1fr)); } } -@media (min-width: 1200px){ .radius-scope .cols-2-xl { grid-template-columns: repeat(2, minmax(0,1fr)); } } -@media (max-width: 899.98px){ .radius-scope .cols-1@sm { grid-template-columns: 1fr; } } -.radius-scope .badge { display:inline-block; padding:2px 8px; border-radius:999px; background:#eef6fb; color:#0b3a57; font-size:12px; border:1px solid #d6e8f5; } -.radius-scope .h4 { font-size:18px; font-weight:800; letter-spacing:.2px; user-select: none; } -.radius-scope .h5 { font-size:16px; font-weight:800; letter-spacing:.2px; user-select: none; } -.radius-scope .cluster { display:flex; gap:10px; flex-wrap:wrap; align-items: center; } -.radius-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 0 auto; padding: 20px 0; } -.radius-scope .card, .radius-scope .subcard, .radius-scope .progress-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } -.radius-scope .card { padding: 14px; } -.radius-scope .subcard { padding: 12px; } -.radius-scope .pane-header { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap: wrap;} -.radius-scope .pane-header .title { display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.2px; font-size: 18px; user-select: none; } -.radius-scope .logo-dot { width:14px; height:14px; border-radius:50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display:inline-block; } -.radius-scope .view-tabs { display:flex; gap:8px; flex-wrap:wrap; } -.radius-scope .view-select-wrap { display: none; } -.radius-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; } -@media (max-width: 800px) { .radius-scope .view-tabs { display: none; } .radius-scope .view-select-wrap { display: block; } } -.radius-scope .tab-btn, .radius-scope .primary-btn, .radius-scope .ghost-btn, .radius-scope .icon-btn, .radius-scope .link-btn, .radius-scope .danger-btn { appearance:none; outline:none; border:none; cursor:pointer; font-weight:700; letter-spacing:.2px; transition: transform .12s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; user-select: none; } -.radius-scope .tab-btn { padding: 8px 12px; border-radius: var(--radius-pill); background: #f4f7fb; color: var(--text); border: 1px solid var(--border); } -.radius-scope .tab-btn.active, .radius-scope .tab-btn:hover { background: #eef6fb; border-color: #d6e8f5; box-shadow: 0 0 0 4px var(--ring); transform: scale(0.98); } -.radius-scope .tab-btn:disabled { opacity: .6; cursor: not-allowed; background: #f4f7fb; border-color: var(--border); box-shadow: none; transform: none; } -.radius-scope .primary-btn { padding: 8px 14px; border-radius: var(--radius); color:#fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 18px rgba(0,83,132,.25); height: 38px; display: inline-flex; align-items: center; justify-content: center; } -.radius-scope .primary-btn:disabled { opacity:.6; cursor:not-allowed; } -.radius-scope .ghost-btn { padding: 8px 12px; border-radius: var(--radius); color: var(--accent); background: #f8fbff; border:1px dashed #cfe4f3; display: inline-flex; align-items: center; justify-content: center; min-height: 38px; } -.radius-scope .danger-btn { padding: 8px 12px; border-radius: var(--radius); color: #c92a2a; background: #fff5f5; border: 1px dashed #ffc9c9; opacity: .9; transition: opacity .2s ease-in-out, transform .1s ease-in-out; } -.radius-scope .danger-btn:hover { opacity: 1; } -.radius-scope .danger-btn:active { transform: scale(0.97); } -.radius-scope .primary-btn:not(:disabled):hover, .radius-scope .ghost-btn:not(:disabled):hover, .radius-scope .danger-btn:not(:disabled):hover { transform: translateY(-2px); } -.radius-scope .primary-btn:not(:disabled):hover { box-shadow: 0 8px 22px rgba(0,83,132,.3); } -.radius-scope .icon-btn { background: transparent; color: var(--muted); padding: 6px 8px; border-radius:8px; } -.radius-scope .icon-btn.sm { padding: 4px 6px; } -.radius-scope .icon-btn:hover { color: var(--text); background:#f2f6fa; } -.radius-scope .link-btn { background: transparent; color: var(--accent); text-decoration: underline; } -@keyframes copy-feedback-pop { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } -.radius-scope [data-tooltip="Kopieren"], .radius-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; } -.radius-scope .icon-btn .check-icon { display: none; } -.radius-scope .icon-btn.is-copied, .radius-scope .icon-btn.is-copied:hover { background-color: #eaf7ef; color: var(--ok); animation: copy-feedback-pop 0.3s ease-in-out; } -.radius-scope .icon-btn.is-copied .copy-icon { display: none; } -.radius-scope .icon-btn.is-copied .check-icon { display: inline-block; } -.radius-scope .input-wrap { position: relative; } -.radius-scope .ri { box-sizing: border-box; width: 100%; padding: 8px 38px 8px 36px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; color: var(--text); transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; } -.radius-scope .ac-root .ri { padding: 8px 38px 8px 75px; } -.radius-scope .ri:hover:not(:focus) { border-color: #c4d1de; } -.radius-scope .ri:focus { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; background: #fbfeff; } -.radius-scope .ri::placeholder{ color:#9aa6b2; } -.radius-scope .input-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color:#7997ad; font-size: 14px; pointer-events: none; } -.radius-scope .input-icon-logo { height: 20px; width: auto; opacity: 0.9; } -.radius-scope .btn-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 28px; height: 28px; border-radius: 8px; border: none; background: transparent; color:#5a7891; cursor: pointer; transition: all .2s ease; opacity: 1; } -.radius-scope .btn-clear:not(:disabled):hover { background:#e8f2f9; color:#2b5c7e; } -.radius-scope .btn-clear:disabled { background: transparent; color: #c1cbd5; cursor: not-allowed; transform: scale(0.9); opacity: 0.5; } -.radius-scope .btn-clear:disabled:hover { background: transparent; color: #c1cbd5; } -.radius-scope .logo-switcher { position: absolute; left: 1px; top: 1px; height: calc(100% - 2px); display: flex; align-items: center; gap: 8px; padding: 0 4px 0 8px; cursor: pointer; border-right: 1px solid var(--border); transition: background-color .2s ease; border-radius: 9px 0 0 9px; user-select: none; } -.radius-scope .logo-switcher:hover { background-color: #f8fafc; } -.radius-scope .switcher-caret { font-size: 11px; color: var(--muted); transition: transform .2s ease; } -.radius-scope .logo-switcher.is-open .switcher-caret { transform: rotate(180deg); } -.radius-scope .logo-dropdown { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; width: 180px; } -.radius-scope .logo-option { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; } -.radius-scope .logo-option:hover { background-color: #f3f8fc; } -.radius-scope .logo-option img { height: 18px; width: auto; } -.radius-scope .select select { width:100%; padding:10px 12px; border-radius: var(--radius); border:1px solid var(--border); background:#fff; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right .5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; padding-right: 2.5rem; } -.radius-scope .switch-field { display:flex; flex-direction:column; gap:6px;align-items: center } -.radius-scope .switch { display:inline-flex; align-items:center; cursor:pointer; user-select:none; } -.radius-scope .switch input { display:none; } -.radius-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display:inline-flex; align-items:center; justify-content:space-between; padding: 0 8px; color:#7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; } -.radius-scope .switch .on { opacity: 0; transition: opacity .18s ease; } -.radius-scope .switch .off { opacity: 1; transition: opacity .18s ease; } -.radius-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background:#fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; } -.radius-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color:#fff; } -.radius-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); } -.radius-scope .switch input:checked + .switch-track::after { transform: translateX(26px); } -.radius-scope .switch input:checked + .switch-track .on { opacity: 1; } -.radius-scope .switch input:checked + .switch-track .off { opacity: 0; } -.radius-scope .ac-root { position: relative; } -.radius-scope .ac-panel { position: absolute; left: 0; min-width: 100%; width: auto; margin-top: 6px; z-index: 20; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); padding: 8px; } -.radius-scope .ac-panel.wide, .radius-scope [data-wide="1"] .ac-panel { left: -6px; right: auto; } -.radius-scope .ac-skel .skeleton-line { height: 12px; margin: 8px 0; } -.radius-scope .ac-empty { padding: 10px; } -.radius-scope .ac-list { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; } -.radius-scope .ac-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: transform .1s ease, background-color .1s ease; white-space: nowrap; } -.radius-scope .ac-item:hover, .radius-scope .ac-item.is-active { background:#f3f8fc; transform: scale(0.99); } -.radius-scope .ac-more-info { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--card-2); font-style: italic; cursor: default; } -.radius-scope .ac-more-info .txt { color: var(--muted); } -.radius-scope .ac-pop-enter-active, .radius-scope .ac-pop-leave-active { transition: opacity .12s ease, transform .12s ease; transform-origin: top center; } -.radius-scope .ac-pop-enter, .radius-scope .ac-pop-leave-to { opacity:0; transform: translateY(-4px) scale(.98); } -.radius-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; } -@media (max-width: 1200px) { .radius-scope .filters-layout { grid-template-columns: 1fr 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } } -@media (max-width: 768px) { .radius-scope .filters-layout { grid-template-columns: 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: auto; } } -.radius-scope .field label { display:block; margin: 0 0 6px; color: var(--muted); font-size: 12px; } -.radius-scope .table-wrap { overflow:auto; border-radius: 12px; border:1px solid var(--border); background: var(--card-2); max-height: 65vh; } -.radius-scope .table-wrap::-webkit-scrollbar { width: 8px; height: 8px; } -.radius-scope .table-wrap::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; } -.radius-scope .table-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; border: 2px solid #f1f5f9; } -.radius-scope .table-wrap::-webkit-scrollbar-thumb:hover { background: #94a3b8; } -.radius-scope .tt-table { width:100%; min-width: 1000px; border-collapse: collapse; background: #fff; table-layout: fixed; margin-bottom: unset !important; } -.radius-scope .tt-table.no-min-width { min-width: auto; } -.radius-scope .tt-table th, .radius-scope .tt-table td { padding: 10px 12px; border-bottom:1px solid #eef1f5; vertical-align: middle; } -.radius-scope .tt-table thead th { position:sticky; top:0; background:#f6f9fc; font-size:12px; color:#344054; text-transform:uppercase; letter-spacing:.04em; user-select: none; z-index: 10; } -.radius-scope .tt-table.compact th, .radius-scope .tt-table.compact td { padding:8px 10px; } -.radius-scope .tt-table.ultra-compact th, .radius-scope .tt-table.ultra-compact td { padding:6px 8px; font-size:12px; } -.radius-scope .rows-enter-active, .radius-scope .rows-leave-active { transition: opacity .12s ease, transform .12s ease; } -.radius-scope .rows-enter, .radius-scope .rows-leave-to { opacity:0; transform: translateY(2px); } -.radius-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; } -.radius-scope .results-summary { padding: 8px 12px; border: 1px solid var(--border); border-top: none; background: #f6f9fc; font-size: 13px; color: var(--muted); border-radius: 0 0 12px 12px; min-height: 38px; display: flex; align-items: center; } -.radius-scope .table-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--card-2); text-align: center; color: var(--muted); font-size: 16px; } -.radius-scope .table-placeholder i { font-size: 32px; color: var(--brand-blue); } -.radius-scope .row-fade-in { animation: rowIn .22s ease; } -@keyframes rowIn { from { opacity:0; transform: translateY(2px);} to {opacity:1; transform: none;} } -.radius-scope .skeleton-line { --h: 12px; height: var(--h); border-radius: 8px; background: linear-gradient(90deg, #eaeef3, #f3f6fa, #eaeef3); background-size: 300% 100%; animation: shimmer 1.1s infinite linear; } -@keyframes shimmer { 0%{background-position:0% 0} 100%{background-position:100% 0} } -.radius-scope .btn-loader { width: 18px; height: 18px; border: 2px solid #d5e7f4; border-top-color: var(--brand-blue); border-radius:50%; display:inline-block; animation: spin .9s linear infinite; } -@keyframes spin { to { transform: rotate(360deg);} } -.radius-scope.modal-overlay { position: fixed; inset:0; background: rgba(0,0,0,.25); display:flex; align-items:center; justify-content:center; padding: 20px; z-index: 9999; } -.radius-scope .modal-card { width:min(780px, 92vw); max-height: 88vh; overflow:auto; border-radius: 16px; border:1px solid var(--border); background: #fff; } -.radius-scope .modal-head { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom:1px solid var(--border); position:sticky; top:0; background: #fff; z-index: 10; user-select: none; } -.radius-scope .modal-title { font-weight:800; } -.radius-scope .modal-body { padding: 14px 16px; } -.radius-scope .fade-enter-active, .radius-scope .fade-leave-active { transition: opacity .14s ease; } -.radius-scope .fade-enter, .radius-scope .fade-leave-to { opacity:0; } -.radius-scope .pop { animation: pop .16s ease; } -@keyframes pop { from { transform: scale(.98);} to { transform: none;} } -.radius-scope .kv { display:grid; grid-template-columns: 180px 1fr; gap: 10px 16px; } -.radius-scope .kv > div { display: contents; } -.radius-scope .kv > div > span { color: var(--muted); } -.radius-scope .kv-redesign { display: flex; flex-direction: column; } -.radius-scope .kv-redesign .kv-row { display: flex; align-items: flex-start; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid var(--border); gap: 16px; } -.radius-scope .kv-redesign .kv-row:last-child { border-bottom: none; } -.radius-scope .kv-redesign .kv-label { color: var(--muted); flex-shrink: 0; width: 140px; } -.radius-scope .kv-redesign .kv-value { flex-grow: 1; text-align: right; word-break: break-all; min-width: 0; } -.radius-scope .kv-redesign .chip { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); } -.radius-scope .kv-redesign .chip.ok { background: #eaf7ef; color:#206a42; border-color: #c9e6d8; } -.radius-scope .kv-redesign .chip.bad { background: #fdecec; color:#8a1d1d; border-color: #f6d2d2; } -.radius-scope .ros-wrap { min-height: 28px; display:flex; align-items:center; justify-content:flex-start; width: 170px; } -.radius-scope .ros-chip { display:flex; align-items:center; gap:8px; padding:4px 8px; border-radius: var(--radius); font-size:12px; font-family: var(--mono); border:1px solid var(--border); background:#fff; width: 100%; height: 28px; box-sizing: border-box; } -.radius-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; } -.radius-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; } -.radius-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); } -.radius-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); } -.radius-scope .ros-chip .dot { width:8px; height:8px; border-radius:50%; background: currentColor; color: inherit; flex-shrink: 0; } -.radius-scope .ros-chip.on .dot { background: var(--ok); } -.radius-scope .ros-chip.off .dot { background: var(--bad); } -.radius-scope .ros-chip .ip { flex-grow: 1; text-align: center; } -.radius-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; } -.radius-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; } -.radius-scope .ont-card .block { background: #fff; border:1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); } -.radius-scope .ont-card .block + .block { margin-top: 12px; } -.radius-scope .block-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 10px; flex-wrap: wrap; } -.radius-scope .file-drop { display: flex; align-items: center; justify-content: center; border: 2px dashed #cfe4f3; border-radius: var(--radius); padding: 20px; text-align: center; background: #f8fbff; cursor: pointer; transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease, background-color .2s ease; min-height: 150px; } -.radius-scope .file-drop.is-dragover { transform: scale(1.02); border-color: var(--accent); background-color: #f0f8ff; box-shadow: 0 0 0 5px var(--ring); } -.radius-scope .file-cta { display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; color:#365972; } -.radius-scope .overlay { position:fixed; inset:0; background:rgba(255,255,255,.8); backdrop-filter: blur(4px); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index: 50; text-align: center; } -.radius-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); } -.radius-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; } -.radius-scope .animated-hourglass { animation: hourglass-turn 2s infinite linear; } -@keyframes hourglass-turn { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } -.radius-scope .progress-bar { height: 8px; background:#eef4f8; border-radius:999px; overflow:hidden; border:1px solid #e2ebf3; } -.radius-scope .progress-bar .bar { height:100%; width:0; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s ease; } -.radius-scope .progress-bar.is-yellow .bar { background: linear-gradient(90deg, #f7b733, #fc4a1a); } -.radius-scope .alert.error { padding:10px 12px; border-radius:10px; border:1px solid #ffd6d6; background:#fff3f3; color:#8a1d1d; } -.radius-scope .card-in { animation: cardIn .18s ease; } -@keyframes cardIn { from{ opacity:0; transform: translateY(4px);} to { opacity:1; transform: none;} } -[data-tooltip] { position: relative; } -[data-tooltip]::before, [data-tooltip]::after { position: absolute; left: 50%; transform: translateX(-50%) translateY(0); opacity: 0; pointer-events: none; transition: all .18s ease-in-out; z-index: 10001; } -[data-tooltip]::before { content: ''; bottom: 100%; border: 5px solid transparent; border-top-color: #0b1320; } -[data-tooltip]::after { content: attr(data-tooltip); bottom: calc(100% + 5px); padding: 4px 8px; border-radius: 6px; background: #0b1320; color: #fff; font-size: 12px; font-weight: 500; white-space: nowrap; } -[data-tooltip]:hover::before, [data-tooltip]:hover::after { opacity: 1; transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="right"]::after { left: 0; transform: translateX(0); } -[data-tooltip-align="right"]::before { left: 1em; transform: translateX(-50%); } -[data-tooltip-align="right"]:hover::after, [data-tooltip-align="right"]:hover::before { transform: translateX(0) translateY(-4px); } -[data-tooltip-align="right"]:hover::before { transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="left"]::after { left: auto; right: 0; transform: translateX(0); } -[data-tooltip-align="left"]::before { left: auto; right: 1em; transform: translateX(-50%); } -[data-tooltip-align="left"]:hover::after, [data-tooltip-align="left"]:hover::before { transform: translateX(0) translateY(-4px); } -[data-tooltip-align="left"]:hover::before { transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="bottom"]::after { top: calc(100% + 5px); bottom: auto; } -[data-tooltip-align="bottom"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; } -[data-tooltip-align="bottom"]:hover::before, [data-tooltip-align="bottom"]:hover::after { transform: translateX(-50%) translateY(4px); } -[data-tooltip-wrap="true"]::after { white-space: normal; max-width: 220px; text-align: center; } -/* NEW RULE FOR BOTTOM-LEFT ALIGNMENT */ -[data-tooltip-align="bottom-left"]::after { top: calc(100% + 5px); bottom: auto; left: auto; right: 0; transform: translateX(0); } -[data-tooltip-align="bottom-left"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; left: auto; right: 1em; transform: translateX(50%); } -[data-tooltip-align="bottom-left"]:hover::after, [data-tooltip-align="bottom-left"]:hover::before { transform: translateY(4px); } -[data-tooltip-align="bottom-left"]:hover::before { transform: translateX(50%) translateY(4px); } -.radius-scope .ip-field-wrapper, .radius-scope .ac-root { position: relative; } -.radius-scope .ip-focus-tooltip, .radius-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 4px); left: 0; background: #f8fbff; border: 1px solid #cfe4f3; padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--accent); white-space: nowrap; opacity: 0; transform: translateY(4px); pointer-events: none; transition: all .18s ease-in-out; z-index: 10; } -.radius-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .radius-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); } -.radius-scope .modal-card-wide { width: min(1100px, 92vw); } -.radius-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; } -.radius-scope .unselectable { user-select: none; } -.radius-scope .custom-dropdown { position: relative; width: 120px; } -.radius-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; } -.radius-scope .dropdown-toggle:hover { border-color: #c4d1de; } -.radius-scope .dropdown-toggle:focus, .radius-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; } -.radius-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; } -.radius-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); } -.radius-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; } -.radius-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; } -.radius-scope .dropdown-item:hover, .radius-scope .dropdown-item.is-active { background-color: #f3f8fc; } -.radius-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); } -.radius-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; } -.radius-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; } -.radius-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); } -.radius-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; } -.radius-scope .stat-card-v2.stat-total .stat-value { color: var(--text); } -.radius-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; } -.radius-scope .stat-card-v2.stat-download .stat-value { color: #15803d; } -.radius-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; } -.radius-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); } -.radius-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; } -.radius-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); } -.radius-scope .stat-card-v2-skeleton { display: flex; align-items: center; gap: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); } -.radius-scope .stat-card-v2-skeleton .icon { width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; } -.radius-scope .stat-card-v2-skeleton .text { flex-grow: 1; } -.radius-scope .stat-card-v2-skeleton .label { height: 12px; width: 70%; margin-bottom: 6px; } -.radius-scope .stat-card-v2-skeleton .value { height: 18px; width: 90%; } -.radius-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; } -.radius-scope .chart-card canvas { max-height: calc(250px - 32px); } -.radius-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); } -.radius-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; } -.radius-scope .modal-skeleton .skeleton-line { margin-bottom: 12px; } -.radius-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; } -.radius-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; } \ No newline at end of file +/* ===== Radius Module Styles ===== */ +/* General utilities moved to tt-core.css - this file contains ONLY Radius-specific styles */ + +/* CSS Variables for backwards compatibility */ +:root { + --brand-blue: #005384; + --bg: #ffffff; + --card: #ffffff; + --card-2: #f8fafc; + --muted: #667085; + --text: #0b1320; + --accent: var(--brand-blue); + --accent-2: #1e88c9; + --ok: #0f9d58; + --bad: #e03131; + --ring: rgba(0,83,132,.20); + --border: #e6e9ef; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 10px; + --radius-pill: 999px; + --shadow: 0 8px 24px rgba(0, 83, 132, .08); + --line-offset: 32px; +} + +/* Radius-specific layouts */ +.tt-scope .free-users-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +@media (max-width: 1100px) { .tt-scope .free-users-grid { grid-template-columns: 1fr; } } +.tt-scope .free-users-column { display: flex; flex-direction: column; gap: 12px; } +.tt-scope .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef6fb; color: #0b3a57; font-size: 12px; border: 1px solid #d6e8f5; } +.tt-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 24px auto 0; padding: 20px 0; } +.tt-scope .subcard { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px; } +.tt-scope .pane-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: linear-gradient(135deg, #f8fbff 0%, #f0f7fd 100%); padding: 16px 20px; margin: -14px -14px 14px -14px; border-radius: var(--radius) var(--radius) 0 0; border-bottom: 2px solid #e3f0f8; } +.tt-scope .pane-header .title { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .4px; font-size: 22px; user-select: none; color: var(--accent); text-shadow: 0 1px 2px rgba(0,83,132,.1); } +.tt-scope .logo-dot { width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display: inline-block; } +.tt-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; } + +/* Switch Field */ +.tt-scope .switch-field { display: flex; flex-direction: column; gap: 6px; align-items: center; } +.tt-scope .switch { display: inline-flex; align-items: center; cursor: pointer; user-select: none; } +.tt-scope .switch input { display: none; } +.tt-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display: inline-flex; align-items: center; justify-content: space-between; padding: 0 8px; color: #7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; } +.tt-scope .switch .on { opacity: 0; transition: opacity .18s ease; } +.tt-scope .switch .off { opacity: 1; transition: opacity .18s ease; } +.tt-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background: #fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; } +.tt-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color: #fff; } +.tt-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); } +.tt-scope .switch input:checked + .switch-track::after { transform: translateX(26px); } +.tt-scope .switch input:checked + .switch-track .on { opacity: 1; } +.tt-scope .switch input:checked + .switch-track .off { opacity: 0; } + +/* Filters Layout */ +.tt-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; margin-bottom: 16px; } +@media (max-width: 1400px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1) { grid-column: 1 / -1; } } +@media (max-width: 900px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1), .tt-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } } +@media (max-width: 600px) { .tt-scope .filters-layout { grid-template-columns: 1fr; } .tt-scope .filters-layout .field { grid-column: auto !important; } } +.tt-scope .field label { display: block; margin: 0 0 6px; color: var(--muted); font-size: 12px; } +.tt-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; } +.tt-scope .inline-copy { display: flex; align-items: center; gap: 8px; justify-content: flex-end; } +.tt-scope .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.tt-scope .clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + +/* KV Layouts */ +.tt-scope .kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 16px; } +.tt-scope .kv > div { display: contents; } +.tt-scope .kv > div > span { color: var(--muted); } + +/* Key-Value Redesign Layout - moved to tt-core.css */ + +/* Radius Online Status Chip */ +.tt-scope .ros-wrap { min-height: 28px; display: flex; align-items: center; justify-content: flex-start; width: 170px; } +.tt-scope .ros-chip { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: var(--radius); font-size: 12px; font-family: var(--mono); border: 1px solid var(--border); background: #fff; width: 100%; height: 28px; box-sizing: border-box; } +.tt-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; } +.tt-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; } +.tt-scope [data-tooltip="Kopieren"], .tt-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; } +.tt-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); } +.tt-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); } +.tt-scope .ros-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; color: inherit; flex-shrink: 0; } +.tt-scope .ros-chip.on .dot { background: var(--ok); } +.tt-scope .ros-chip.off .dot { background: var(--bad); } +.tt-scope .ros-chip .ip { flex-grow: 1; text-align: center; } +.tt-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; } +.tt-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; } + +/* ONT Card Styles */ +.tt-scope .ont-card .block { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); } +.tt-scope .ont-card .block + .block { margin-top: 12px; } +.tt-scope .block-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } + +/* Radius-Specific Tooltips */ +.tt-scope .ip-field-wrapper, .tt-scope .ac-root { position: relative; } +.tt-scope .ip-focus-tooltip, .tt-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 8px); left: 0; background: linear-gradient(135deg, #e3f0f8 0%, #d6e8f5 100%); border: 1px solid #b8d9f0; padding: 6px 12px; border-radius: 8px; font-size: 11px; font-weight: 600; color: #0b3a57; white-space: nowrap; opacity: 0; transform: translateY(6px); pointer-events: none; transition: all .22s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 50; box-shadow: 0 4px 12px rgba(0, 83, 132, .15), 0 0 0 1px rgba(255, 255, 255, .8) inset; } +.tt-scope .ip-focus-tooltip::before, .tt-scope .ac-focus-tooltip::before { content: ''; position: absolute; top: 100%; left: 16px; border: 6px solid transparent; border-top-color: #d6e8f5; transform: translateY(-1px); } +.tt-scope .ip-focus-tooltip::after, .tt-scope .ac-focus-tooltip::after { content: ''; position: absolute; top: 100%; left: 17px; border: 5px solid transparent; border-top-color: #e3f0f8; } +.tt-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .tt-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); } + +/* Modal & Misc */ +.tt-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; overflow-x: hidden; } +.tt-scope .unselectable { user-select: none; } + +/* Custom Dropdown */ +.tt-scope .custom-dropdown { position: relative; width: 120px; } +.tt-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; } +.tt-scope .dropdown-toggle:hover { border-color: #c4d1de; } +.tt-scope .dropdown-toggle:focus, .tt-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; } +.tt-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; } +.tt-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); } +.tt-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; } +.tt-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; } +.tt-scope .dropdown-item:hover, .tt-scope .dropdown-item.is-active { background-color: #f3f8fc; } + +/* Stat Cards V2 */ +.tt-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); } +.tt-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; } +.tt-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; } +.tt-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); } +.tt-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; } +.tt-scope .stat-card-v2.stat-total .stat-value { color: var(--text); } +.tt-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; } +.tt-scope .stat-card-v2.stat-download .stat-value { color: #15803d; } +.tt-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; } +.tt-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); } +.tt-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; } +.tt-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); } + +/* Chart Card */ +.tt-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; } +.tt-scope .chart-card canvas { max-height: calc(250px - 32px); } +.tt-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); } +.tt-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; } +.tt-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; } +.tt-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; } +.tt-scope .overlay { position: fixed; inset: 0; background: rgba(255,255,255,.8); backdrop-filter: blur(4px); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 50; text-align: center; } +.tt-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); } +.tt-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; } +.tt-scope .alert.error { padding: 10px 12px; border-radius: 10px; border: 1px solid #ffd6d6; background: #fff3f3; color: #8a1d1d; } +.tt-scope .card-in { animation: cardIn .18s ease; } +@keyframes cardIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +/* Network Mesh Visualization */ +.tt-scope .network-tree-container { overflow: auto; padding: 40px; display: inline-flex; justify-content: flex-start; align-items: flex-start; } +.tt-scope .mesh-node { display: flex; flex-direction: row; align-items: flex-start; position: relative; } +.tt-scope .mesh-content { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; width: 240px; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); display: flex; align-items: center; gap: 10px; z-index: 2; position: relative; transition: all 0.2s ease; margin: 5px 0; } +.tt-scope .mesh-content:hover { box-shadow: 0 4px 8px rgba(0,0,0,0.1); border-color: #ccc; } +.tt-scope .mesh-content.is-router { border-color: #005384; background-color: #f0f7fa; } +.tt-scope .mesh-content.is-repeater { border-color: #0f9d58; background-color: #f0fbf5; } +.tt-scope .mesh-content.is-offline { opacity: 0.7; filter: grayscale(100%); } +.tt-scope .mesh-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #555; position: relative; flex-shrink: 0; } +.tt-scope .conn-badge { position: absolute; bottom: -2px; right: -2px; width: 16px; height: 16px; background: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; box-shadow: 0 1px 2px rgba(0,0,0,0.2); } +.tt-scope .conn-badge.wlan { color: #005384; } +.tt-scope .conn-badge.eth { color: #0f9d58; } +.tt-scope .mesh-info { flex-grow: 1; min-width: 0; } +.tt-scope .mesh-name { font-weight: 600; font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tt-scope .mesh-meta { display: flex; gap: 6px; align-items: center; margin-top: 2px; } +.tt-scope .mesh-ip, .tt-scope .mesh-mac { font-size: 10px; color: #777; font-family: var(--mono); } +.tt-scope .mesh-vendor { font-size: 10px; color: var(--accent); font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tt-scope .mesh-details { font-size: 10px; color: #555; margin-top: 4px; background: #eee; padding: 1px 4px; border-radius: 4px; display: inline-block; } +.tt-scope .mesh-children { display: flex; flex-direction: column; margin-left: 80px; position: relative; justify-content: center; } +.tt-scope .mesh-branch { display: flex; align-items: center; position: relative; padding-left: 40px; } +.tt-scope .mesh-branch::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; border-left: 2px solid #ccc; display: block; } +.tt-scope .mesh-branch::after { content: ''; position: absolute; left: 0; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; } +.tt-scope .mesh-branch:first-child::before { top: var(--line-offset); } +.tt-scope .mesh-branch:last-child::before { bottom: auto; height: var(--line-offset); } +.tt-scope .mesh-branch:only-child::before { display: none; } +.tt-scope .mesh-content::before { content: ''; position: absolute; left: -40px; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; } +.tt-scope .network-tree-container > .mesh-node > .mesh-content::before { display: none; } +.tt-scope .mesh-node > .mesh-content:not(:last-child)::after { content: ''; position: absolute; right: -80px; top: var(--line-offset); width: 80px; border-top: 2px solid #ccc; } + +/* Tooltip Fixes for Table Actions */ +.tt-scope .table-wrap [data-tooltip]::before, +.tt-scope .table-wrap [data-tooltip]::after { + position: fixed; + z-index: 10002; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after { + left: auto; + right: 100%; + transform: translateX(0) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before { + top: 50%; + bottom: auto; + border: 5px solid transparent; + border-left-color: #0b1320; + border-top-color: transparent; + margin-right: -10px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after { + top: 50%; + bottom: auto; + margin-right: -5px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::after { + transform: translateX(-4px) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after { + left: 100%; + right: auto; + transform: translateX(0) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before { + top: 50%; + bottom: auto; + border: 5px solid transparent; + border-right-color: #0b1320; + border-top-color: transparent; + margin-left: -10px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after { + top: 50%; + bottom: auto; + margin-left: -5px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::after { + transform: translateX(4px) translateY(-50%); +} + +/* Router Management Modal */ +.tt-scope .router-info-header { display: flex; align-items: center; gap: 12px; padding: 20px 24px; margin: -14px -24px 12px -16px; background: linear-gradient(135deg, #e3f0f8 0%, #cce4f5 100%); border-bottom: 2px solid #b8d9f0; } +.tt-scope .router-info-header i { font-size: 28px; color: var(--accent); flex-shrink: 0; } +.tt-scope .router-header-text { flex-grow: 1; min-height: 39px; } +.tt-scope .router-title { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; line-height: 1.375; } +.tt-scope .router-subtitle { font-size: 12px; color: var(--muted); font-family: var(--mono); margin-top: 2px; line-height: 1.25; } +.tt-scope .router-info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 16px; margin-right: -8px; } +@media (max-width: 700px) { .tt-scope .router-info-grid { grid-template-columns: 1fr; } } + +/* Info Card Styles - moved to tt-core.css (TtInfoCard component) */ + +.tt-scope .router-actions-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); margin-right: -8px; } +.tt-scope .router-actions-header { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 12px; letter-spacing: 0.3px; text-transform: uppercase; user-select: none; } +.tt-scope .router-actions-header i { font-size: 14px; color: var(--accent); } +.tt-scope .router-actions-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +@media (max-width: 700px) { .tt-scope .router-actions-grid { grid-template-columns: 1fr; } } +.tt-scope .action-btn { display: flex; align-items: center; padding: 8px 16px; } +.tt-scope .action-btn i { width: 20px; flex-shrink: 0; text-align: center; margin-right: 10px; } diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js index 13891b3be..f7f97cd2a 100644 --- a/public/js/pages/Radius/Radius.js +++ b/public/js/pages/Radius/Radius.js @@ -1,360 +1,104 @@ -/* ===== 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: ` -
-
- -
{{ initialPlaceholderText }}
-
-
- -
- - - -
-
-
-
-
- -
{{ noResultsPlaceholderText }}
-
- -
- ` -}); - -/* ---------- Reusable Component: radius-file-drop ---------- */ -Vue.component('radius-file-drop', { - data: () => ({ dragCounter: 0 }), - computed: { isDragging() { return this.dragCounter > 0; } }, - template: ` - - `, - 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: ` -
- -
Verarbeitung läuft...
-

Aktuell: {{ currentSerial || '—' }}

-
-
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
-
- ` -}); - -/* ---------- 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: ` -
- - -
- `, - 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: `
Klicken Sie auf das Logo, um die Kundenbasis zu wechseln
Xinon LogoXINON (Suche)
ESTMK LogoESTMK (Eingabe)
`, - 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: ` - - - - `, - 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 = ''; - } -}); - +/* ===== Radius.js (Vue 3 + TT-Core) ===== */ /* ---------- Root View: ---------- */ -Vue.component('radius', { +const Radius = { + name: 'Radius', template: ` -
+
-
Radius
+
+
+ + Radius +
+ +
+ +
+

-
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
`, - data() { return { view: 'users', window: window, _initFlags: {} }; }, + 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; + const options = [ + { 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') { + options.push( + { id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' }, + { id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' } + ); + } + + return options; } }, - mounted() { this.switchView(this.view); }, + 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;}});}} + switchView(v) { + this.view = v; + + if (!this._initFlags || this._initFlags[v]) return; + + let refName = ''; + if (v === 'free') refName = 'freeView'; + else if (v === 'unused') refName = 'unusedView'; + + if (refName) { + this.$nextTick(() => { + const childComponent = this.$refs[refName]; + if (childComponent && typeof childComponent.initIfNeeded === 'function') { + childComponent.initIfNeeded(); + this._initFlags[v] = true; + } + }); + } + } } -}); +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius', Radius); +} diff --git a/public/js/pages/Radius/RadiusFreeUsers.js b/public/js/pages/Radius/RadiusFreeUsers.js index 9c4a9ffa9..47748dd3b 100644 --- a/public/js/pages/Radius/RadiusFreeUsers.js +++ b/public/js/pages/Radius/RadiusFreeUsers.js @@ -1,48 +1,159 @@ -/* ===== RadiusFreeUsers.js ===== */ -Vue.component('radius-free-users', { +/* ===== RadiusFreeUsers.js (Vue 3 + TT-Core) ===== */ + +const RadiusFreeUsers = { + name: 'RadiusFreeUsers', template: ` -
-
-
+
+
+
Freie NAT Benutzer {{ filteredNat.length }} - +
- - - + + + - -
{{ filteredNat.length }} Treffer gefunden
+ +
+ {{ filteredNat.length }} Treffer gefunden +
-
+
Freie STF Benutzer {{ filteredStf.length }} - +
- - - + + + - -
{{ filteredStf.length }} Treffer gefunden
+ +
+ {{ filteredStf.length }} Treffer gefunden +
`, - data: () => ({ nat: [], stf: [], loadingNat: false, loadingStf: false, _initialized: false }), - computed: { filteredNat() { return this.nat.filter(this.isTrulyFree); }, filteredStf() { return this.stf.filter(this.isTrulyFree); } }, + data: () => ({ + nat: [], + stf: [], + loadingNat: false, + loadingStf: false, + _initialized: false + }), + computed: { + filteredNat() { + return this.nat.filter(this.isTrulyFree); + }, + filteredStf() { + return this.stf.filter(this.isTrulyFree); + } + }, methods: { - initIfNeeded(){ if (this._initialized) return; this._initialized = true; this.reloadNat(); this.reloadStf(); }, - isTrulyFree(user) { return !/frei[a-z]/.test((user.Info || '').toLowerCase()); }, - normalizeUsers(arr){ if (!Array.isArray(arr)) return []; return arr.map(u => ({ Username: (u.Username || u.username || '').trim(), Info: (u.Info || u.info || '').toString().replace(/\s+$/,'') })).filter(u => u.Username); }, - async reloadNat() { this.nat = []; this.loadingNat = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat'); this.nat = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.nat = []; } this.loadingNat = false; }, - async reloadStf() { this.stf = []; this.loadingStf = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf'); this.stf = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.stf = []; } this.loadingStf = false; } + initIfNeeded() { + if (this._initialized) return; + this._initialized = true; + this.reloadNat(); + this.reloadStf(); + }, + isTrulyFree(user) { + return !/frei[a-z]/.test((user.Info || '').toLowerCase()); + }, + normalizeUsers(arr) { + if (!Array.isArray(arr)) return []; + return arr.map(u => ({ + Username: (u.Username || u.username || '').trim(), + Info: (u.Info || u.info || '').toString().replace(/\s+$/, '') + })).filter(u => u.Username); + }, + async reloadNat() { + this.nat = []; + this.loadingNat = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'free_user', filter: 'nat' } + }); + this.nat = this.normalizeUsers(data?.users || []); + } catch (error) { + this.nat = []; + } + this.loadingNat = false; + }, + async reloadStf() { + this.stf = []; + this.loadingStf = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'free_user', filter: 'stf' } + }); + this.stf = this.normalizeUsers(data?.users || []); + } catch (error) { + this.stf = []; + } + this.loadingStf = false; + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-free-users', RadiusFreeUsers); +} diff --git a/public/js/pages/Radius/RadiusNetworkNode.js b/public/js/pages/Radius/RadiusNetworkNode.js new file mode 100644 index 000000000..187a2412a --- /dev/null +++ b/public/js/pages/Radius/RadiusNetworkNode.js @@ -0,0 +1,67 @@ +const RadiusNetworkNode = { + name: 'RadiusNetworkNode', + props: { + device: Object + }, + template: ` +
+
+
+ +
+
+
+
+
{{ device.name }}
+
+ {{ device.ipv4.ip }} +
+
+ {{ device.mac }} +
+
{{ device.vendor }}
+
+ {{ details }} +
+
+
+
+
+ +
+
+
+ `, + computed: { + iconClass() { + if (this.device.model === 'fbox') return 'fa-duotone fa-router'; + if ((this.device.name || '').toLowerCase().includes('repeater')) return 'fa-duotone fa-wifi-exclamation'; + if (this.device.type === 'wlan') return 'fa-duotone fa-mobile-screen'; + return 'fa-duotone fa-desktop'; + }, + connectionType() { + if (this.device.type === 'wlan') return 'wlan'; + if (this.device.type === 'ethernet') return 'ethernet'; + return null; + }, + nodeClass() { + return { + 'is-router': this.device.model === 'fbox', + 'is-repeater': (this.device.name || '').toLowerCase().includes('repeater'), + 'is-offline': this.device.state && this.device.state.class !== 'globe_online' && this.device.state.class !== 'led_green' + } + }, + details() { + if (this.device.properties && this.device.properties.length > 0) { + const props = this.device.properties.filter(p => p.txt && p.txt !== 'Mesh'); + if (props.length > 0) return props[0].txt; + } + if (this.device.port && this.device.port !== 'WLAN') return this.device.port; + return null; + } + } +}; + +if (window.VueApp) { + VueApp.component('radius-network-node', RadiusNetworkNode); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusOntFinder.js b/public/js/pages/Radius/RadiusOntFinder.js index 17a2de939..892997650 100644 --- a/public/js/pages/Radius/RadiusOntFinder.js +++ b/public/js/pages/Radius/RadiusOntFinder.js @@ -1,29 +1,203 @@ -/* ===== RadiusOntFinder.js ===== */ -Vue.component('radius-ont-finder', { +/* ===== RadiusOntFinder.js (Vue 3 + TT-Core) ===== */ + +const RadiusOntFinder = { + name: 'RadiusOntFinder', template: ` -
+
-
Schritt 1 · Excel (XLSX) Upload

Datei muss die Spalte Serial enthalten. Optional MAC.

-
{{ uploadError }}
+
+
Schritt 1 · Excel (XLSX) Upload
+

Datei muss die Spalte Serial enthalten. Optional MAC.

+
+ +
{{ uploadError }}
-
Ergebnisse
+
+
Ergebnisse
+
+ + +
+
- - - - - -
{{ processedData.length }} Zeilen verarbeitet
+ + + + + +
+ {{ processedData.length }} Zeilen verarbeitet +
`, - data: () => ({ step: 1, parsedData: [], processedData: [], originalHeaders: [], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentSerial: '', uploadError: null, serialColumnName: 'Serial', macColumnName: 'MAC', fetchedKeys: { username: 'fetched_username', customerNumber: 'fetched_customerNumber', customerName: 'fetched_customerName', info: 'fetched_info' }, apiBasePath: window.TT_CONFIG?.BASE_PATH }), + data: () => ({ + step: 1, + parsedData: [], + processedData: [], + originalHeaders: [], + loading: false, + progress: 0, + currentRow: 0, + totalRows: 0, + currentSerial: '', + uploadError: null, + serialColumnName: 'Serial', + macColumnName: 'MAC', + fetchedKeys: { + username: 'fetched_username', + customerNumber: 'fetched_customerNumber', + customerName: 'fetched_customerName', + info: 'fetched_info' + }, + apiBasePath: window.TT_CONFIG?.BASE_PATH + }), methods: { - resetComponent(){ Object.assign(this.$data, this.$options.data.call(this)); const i=this.$el.querySelector('input[type="file"]'); if (i) i.value=''; }, - async readXlsx(file){ this.uploadError=null; try{ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const arr = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=e=>res(new Uint8Array(e.target.result)); r.onerror=()=>rej(new Error('Fehler beim Lesen.')); r.readAsArrayBuffer(file); }); const wb = XLSX.read(arr, {type:'array'}); const ws = wb.Sheets[wb.SheetNames[0]]; this.parsedData = XLSX.utils.sheet_to_json(ws, {defval:''}); if (!this.parsedData.length) throw new Error('Die Datei ist leer.'); this.originalHeaders = Object.keys(this.parsedData[0]); if (!this.originalHeaders.includes(this.serialColumnName)) throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`); this.startProcessing(); } catch(e){ this.uploadError=e.message; this.step=1; } }, - async startProcessing(){ this.step = 2; this.loading = true; this.totalRows=this.parsedData.length; this.processedData=[]; const setRow = (row, msg, data={})=>{ const d={username:`N/A - ${msg}`,customerNumber:'N/A',customerName:'N/A',info:'N/A'}; Object.keys(this.fetchedKeys).forEach(k=>row[this.fetchedKeys[k]]=data[k]||d[k]); }; for (const [i,row] of this.parsedData.entries()){ this.currentRow=i; const out={...row}; const sn=(''+(row[this.serialColumnName]||'')).trim(); this.currentSerial=`SN: ${sn||'—'}`; let found=false; if (sn){ try{ const r=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=${encodeURIComponent(sn)}`); if(r.ok){ const j=await r.json(); if(Array.isArray(j)&&j.length>0){ setRow(out,'',j[0]); found=true; }}}catch{} } if (!found && this.originalHeaders.includes(this.macColumnName)){ const macRaw=(''+(row[this.macColumnName]||'')).trim(); if(macRaw&&macRaw.length===12){ const mac=macRaw.toUpperCase().match(/.{1,2}/g).join(':'); try{ const s=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=${encodeURIComponent(mac)}`); if(s.ok){ const ses=await s.json(); if(Array.isArray(ses)&&ses.length>0){ const u=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=${encodeURIComponent(ses[0])}&info=&custnum=`); if(u.ok){ const d=await u.json(); if(Array.isArray(d)&&d.length>0) {setRow(out,'',d[0]); found=true;}}}}}catch{}}} if(!found) setRow(out,'Keinen Benutzer gefunden'); this.processedData.push(out); this.progress=((i+1)/this.totalRows)*100; if ((i+1)%20===0) await new Promise(r=>setTimeout(r,20)); } this.loading=false; this.currentSerial=''; }, - downloadResults(){ if (!this.processedData.length) return; try{ const data=this.processedData.map(r=>{ const o={}; this.originalHeaders.forEach(h=>o[h]=r[h]); Object.keys(this.fetchedKeys).forEach(k=>{const K=k.charAt(0).toUpperCase()+k.slice(1).replace('Number','nummer').replace('Name','name'); o[K]=r[this.fetchedKeys[k]];}); return o; }); const ws=XLSX.utils.json_to_sheet(data); const wb=XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb,ws,'ONT_Finder_Results'); XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g,'').slice(0,14)}.xlsx`); } catch { if(window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); } } + resetComponent() { + Object.assign(this.$data, this.$options.data.call(this)); + const i = this.$el.querySelector('input[type="file"]'); + if (i) i.value = ''; + }, + async readXlsx(file) { + this.uploadError = null; + try { + await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); + const arr = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = e => res(new Uint8Array(e.target.result)); + r.onerror = () => rej(new Error('Fehler beim Lesen.')); + r.readAsArrayBuffer(file); + }); + const wb = XLSX.read(arr, {type: 'array'}); + const ws = wb.Sheets[wb.SheetNames[0]]; + this.parsedData = XLSX.utils.sheet_to_json(ws, {defval: ''}); + if (!this.parsedData.length) throw new Error('Die Datei ist leer.'); + this.originalHeaders = Object.keys(this.parsedData[0]); + if (!this.originalHeaders.includes(this.serialColumnName)) + throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`); + this.startProcessing(); + } catch (e) { + this.uploadError = e.message; + this.step = 1; + } + }, + async startProcessing() { + this.step = 2; + this.loading = true; + this.totalRows = this.parsedData.length; + this.processedData = []; + const setRow = (row, msg, data = {}) => { + const d = { + username: `N/A - ${msg}`, + customerNumber: 'N/A', + customerName: 'N/A', + info: 'N/A' + }; + Object.keys(this.fetchedKeys).forEach(k => row[this.fetchedKeys[k]] = data[k] || d[k]); + }; + for (const [i, row] of this.parsedData.entries()) { + this.currentRow = i; + const out = {...row}; + const sn = ('' + (row[this.serialColumnName] || '')).trim(); + this.currentSerial = `SN: ${sn || '—'}`; + let found = false; + if (sn) { + try { + const { data } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { ont_sn: sn } + }); + if (Array.isArray(data) && data.length > 0) { + setRow(out, '', data[0]); + found = true; + } + } catch { + } + } + if (!found && this.originalHeaders.includes(this.macColumnName)) { + const macRaw = ('' + (row[this.macColumnName] || '')).trim(); + if (macRaw && macRaw.length === 12) { + const mac = macRaw.toUpperCase().match(/.{1,2}/g).join(':'); + try { + const { data: ses } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'find_by_current_session', mac } + }); + if (Array.isArray(ses) && ses.length > 0) { + const { data: d } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { username: ses[0], info: '', custnum: '' } + }); + if (Array.isArray(d) && d.length > 0) { + setRow(out, '', d[0]); + found = true; + } + } + } catch { + } + } + } + if (!found) setRow(out, 'Keinen Benutzer gefunden'); + this.processedData.push(out); + this.progress = ((i + 1) / this.totalRows) * 100; + if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20)); + } + this.loading = false; + this.currentSerial = ''; + }, + downloadResults() { + if (!this.processedData.length) return; + try { + const data = this.processedData.map(r => { + const o = {}; + this.originalHeaders.forEach(h => o[h] = r[h]); + Object.keys(this.fetchedKeys).forEach(k => { + const K = k.charAt(0).toUpperCase() + k.slice(1).replace('Number', 'nummer').replace('Name', 'name'); + o[K] = r[this.fetchedKeys[k]]; + }); + return o; + }); + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'ONT_Finder_Results'); + XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 14)}.xlsx`); + } catch { + if (window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); + } + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-ont-finder', RadiusOntFinder); +} diff --git a/public/js/pages/Radius/RadiusOntParser.js b/public/js/pages/Radius/RadiusOntParser.js index cc35ab919..ff072c480 100644 --- a/public/js/pages/Radius/RadiusOntParser.js +++ b/public/js/pages/Radius/RadiusOntParser.js @@ -1,36 +1,185 @@ -/* ===== RadiusOntParser.js ===== */ -Vue.component('radius-ont-parser', { +/* ===== RadiusOntParser.js (Vue 3 + TT-Core) ===== */ + +const RadiusOntParser = { + name: 'RadiusOntParser', template: ` -
+
-
Schritt 1 · Excel (XLSX) Upload
Laden Sie eine XLSX-Datei mit Ihren Kundendaten.
- +
+
Schritt 1 · Excel (XLSX) Upload
+
Laden Sie eine XLSX-Datei mit Ihren Kundendaten.
+
+
-
Schritt 2 · Spaltenzuordnung
-
-
+
+
Schritt 2 · Spaltenzuordnung
+
+
+
+ +
+ +
+
+
+
+ + +
-
Schritt 3 · Ergebnisse
+
+
Schritt 3 · Ergebnisse
+
+ + + +
+
- - - - - - - -
{{ processedData.length }} Zeilen verarbeitet
+ + + + + +
+ {{ processedData.length }} Zeilen verarbeitet +
`, - data: () => ({ step: 1, headers: [], parsedData: [], processedData: [], selectedColumns: { kundennummer: 'crmPartner', anschlussstrasse: 'AnlStrasse', anschlussplz: 'AnlPlz', anschlusscity: 'AnlOrt' }, requiredFields: [ { key: 'kundennummer', label: 'Kundennummer' }, { key: 'anschlussstrasse', label: 'Anschlussstraße' }, { key: 'anschlussplz', label: 'Anschluss PLZ' }, { key: 'anschlusscity', label: 'Anschluss City' } ], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentCustomerNumber: '' }), + data: () => ({ + step: 1, + headers: [], + parsedData: [], + processedData: [], + selectedColumns: { + kundennummer: 'crmPartner', + anschlussstrasse: 'AnlStrasse', + anschlussplz: 'AnlPlz', + anschlusscity: 'AnlOrt' + }, + requiredFields: [ + { key: 'kundennummer', label: 'Kundennummer' }, + { key: 'anschlussstrasse', label: 'Anschlussstraße' }, + { key: 'anschlussplz', label: 'Anschluss PLZ' }, + { key: 'anschlusscity', label: 'Anschluss City' } + ], + loading: false, + progress: 0, + currentRow: 0, + totalRows: 0, + currentCustomerNumber: '' + }), methods: { - async readXlsx(file){ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const fr = new FileReader(); fr.onload = (e)=>{ const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); this.headers = Object.keys(this.parsedData[0] || {}); this.step = 2; }; fr.readAsArrayBuffer(file); }, - async startProcessing(){ this.step = 3; this.loading = true; this.totalRows = this.parsedData.length; this.processedData = []; this.currentRow = 0; const p = []; const b = window.TT_CONFIG.BASE_PATH; loop: for (let i=0; isetTimeout(r,20)); } this.loading=false; this.processedData = p; }, - downloadResults(){ const ws = XLSX.utils.json_to_sheet(this.processedData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Results'); XLSX.writeFile(wb, 'results.xlsx'); }, - resetLocal(){ Object.assign(this.$data, this.$options.data.call(this)); } + async readXlsx(file) { + await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); + const fr = new FileReader(); + fr.onload = (e) => { + const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); + this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + this.headers = Object.keys(this.parsedData[0] || {}); + this.step = 2; + }; + fr.readAsArrayBuffer(file); + }, + async startProcessing() { + this.step = 3; + this.loading = true; + this.totalRows = this.parsedData.length; + this.processedData = []; + this.currentRow = 0; + const p = []; + const b = window.TT_CONFIG.BASE_PATH; + loop: for (let i = 0; i < this.parsedData.length; i++) { + this.currentRow = i; + this.progress = ((i + 1) / this.totalRows) * 100; + const row = { ...this.parsedData[i] }; + this.currentCustomerNumber = row[this.selectedColumns.kundennummer] || ''; + try { + const { data: users } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { custnume: row[this.selectedColumns.kundennummer] } + }); + if (users.length === 0) { + row.ont_sn = 'N/A - Kein Benutzer'; + } else if (users.length === 1) { + const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { skipAdditional: 'true', action2: 'fetchRadacct', username: users[0].username } + }); + row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; + } else { + const [s, pl, c] = [row[this.selectedColumns.anschlussstrasse], row[this.selectedColumns.anschlussplz], row[this.selectedColumns.anschlusscity]]; + for (let u of users) { + if (window.TT_CORE.validateData(s, pl, c, u.info || users[0].info || '')) { + const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { skipAdditional: 'true', action2: 'fetchRadacct', username: u.username } + }); + row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; + p.push(row); + continue loop; + } + } + row.ont_sn = 'N/A - Anschluss nicht zugeordnet'; + } + } catch { + row.ont_sn = 'N/A - Fehler'; + } + p.push(row); + if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20)); + } + this.loading = false; + this.processedData = p; + }, + downloadResults() { + const ws = XLSX.utils.json_to_sheet(this.processedData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Results'); + XLSX.writeFile(wb, 'results.xlsx'); + }, + resetLocal() { + Object.assign(this.$data, this.$options.data.call(this)); + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-ont-parser', RadiusOntParser); +} diff --git a/public/js/pages/Radius/RadiusRadacctModal.js b/public/js/pages/Radius/RadiusRadacctModal.js new file mode 100644 index 000000000..3dc20657e --- /dev/null +++ b/public/js/pages/Radius/RadiusRadacctModal.js @@ -0,0 +1,85 @@ +const RadiusRadacctModal = { + name: 'RadiusRadacctModal', + props: { + show: Boolean, + username: String + }, + template: ` + +
+
Status +
+
{{ radacctData.online ? 'Online' : 'Offline' }} +
+
+
+
+ +
IP +
+
+ {{ radacctData.ip }} + + +
+
+
+
+ +
Username + +
+ + + +
+
+ `, + data: () => ({ + radacctData: null + }), + watch: { + show(val) { + if (val && this.username) { + this.fetchRadacctData(); + } else { + this.radacctData = null; + } + } + }, + methods: { + async fetchRadacctData() { + this.radacctData = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'fetchRadacct', username: this.username } + }); + this.radacctData = data; + } catch (error) { + console.error(error); + this.radacctData = {}; + } + } + } +}; + +if (window.VueApp) { + window.VueApp.component('RadiusRadacctModal', RadiusRadacctModal); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusRouterManager.js b/public/js/pages/Radius/RadiusRouterManager.js new file mode 100644 index 000000000..47289255a --- /dev/null +++ b/public/js/pages/Radius/RadiusRouterManager.js @@ -0,0 +1,474 @@ +const RadiusRouterManager = { + name: 'RadiusRouterManager', + props: { + show: Boolean, + userItem: Object + }, + template: ` +
+ + + + + + + + + + +
+
+
Gesendet{{ pingResult.packetsTransmitted }}
+
Empfangen{{ pingResult.packetsReceived }}
+
Verlust{{ pingResult.packetLoss }}%
+
Min / Avg / Max{{ pingResult.min }} / {{ pingResult.avg }} / {{ pingResult.max }} ms
+
+
+
Kein Ergebnis.
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
#BandbreiteÜbertragenPakete
{{ idx + 1 }}{{ row.bpsFormatted }}{{ row.bytesFormatted }}{{ row.packets }}
+
+
+
+ Aktualisiere... +
+
+ Abgeschlossen +
+
+
+ + + + +
+
+ Konfiguration erfolgreich abgeschlossen. +
+
+
+ Remote Link + +
+
+ Username +
+ {{ remoteAccessResult.username }} + +
+
+
+ Password +
+ {{ remoteAccessResult.password }} + +
+
+
+
+ +
+
+
Ein Fehler ist aufgetreten.
+
+ + + + +
+
+ + +
+
+
Keine Daten verfügbar.
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
DatumUhrzeitGruppeNachricht
{{ event.date }}{{ event.time }}{{ event.group }}{{ event.msg }}
+
+
+
Keine Ereignisse verfügbar.
+
+ +
+ `, + data: () => ({ + routerLoading: false, + routerActionLoading: false, + routerDevice: null, + + // Sub-Modal States + showPingModal: false, + pingResult: null, + + showSpeedtestModal: false, + speedtestLoading: false, + speedtestResult: null, + speedtestHistory: [], + speedtestHasStarted: false, + + showRemoteAccessModal: false, + remoteAccessLoading: false, + remoteAccessResult: null, + remoteAccessStep: '', + + showNetworkStructureModal: false, + networkStructureLoading: false, + rootDevice: null, + + showEventLogModal: false, + eventLogLoading: false, + eventLogData: null + }), + watch: { + show: { + handler(val) { + if (val && this.userItem) { + this.loadRouterData(); + } + }, + immediate: true + }, + userItem(val) { + if (val && this.show) { + this.loadRouterData(); + } + } + }, + methods: { + async loadRouterData() { + this.routerLoading = true; + this.routerDevice = null; + this.pingResult = null; + this.speedtestResult = null; + this.speedtestLoading = false; + + try { + const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'fetchRadacct', username: this.userItem.username } + }); + + if (radacct?.ip) { + const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByIp`, { + params: { ip: radacct.ip } + }); + + if (deviceData?.success) { + this.routerDevice = deviceData; + } + } + } catch (error) { + console.error('Error fetching router:', error); + window.notify('error', 'Fehler beim Laden des Routers'); + } + this.routerLoading = false; + }, + async rebootRouter() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + if (!confirm('Möchten Sie den Router wirklich neu starten?')) return; + + this.routerActionLoading = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRebootDevice`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success) { + window.notify('success', 'Router-Neustart gestartet'); + } else { + window.notify('error', data.message || 'Fehler beim Neustart'); + } + } catch (error) { + console.error('Error rebooting router:', error); + window.notify('error', 'Fehler beim Neustarten des Routers'); + } + this.routerActionLoading = false; + }, + async pingRouter() { + if (!this.routerDevice) return; + const pingIp = this.routerDevice.managementIp || this.routerDevice.ip; + if (!pingIp) return; + + this.showPingModal = true; + this.routerActionLoading = true; + this.pingResult = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsPing`, { + params: { ip: pingIp } + }); + + if (data.success && data.result) { + this.pingResult = data.result; + window.notify('success', 'Ping erfolgreich'); + } else { + window.notify('error', 'Ping fehlgeschlagen'); + } + } catch (error) { + console.error('Error pinging router:', error); + window.notify('error', 'Fehler beim Pingen des Routers'); + } + this.routerActionLoading = false; + }, + async runSpeedtest() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showSpeedtestModal = true; + this.speedtestLoading = true; + this.speedtestResult = null; + this.speedtestHistory = []; + this.speedtestHasStarted = false; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRunSpeedtest`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success) { + this.pollSpeedtestResult(); + } else { + throw new Error(data.message || "Speedtest konnte nicht gestartet werden"); + } + } catch (e) { + window.notify('error', e.response?.data?.message || e.message || 'Fehler beim Starten des Speedtests'); + this.speedtestLoading = false; + } + }, + async pollSpeedtestResult() { + let attempts = 0; + const maxAttempts = 240; + + const poll = async () => { + if (!this.showSpeedtestModal) return; + if (attempts >= maxAttempts) { + this.speedtestLoading = false; + window.notify('error', 'Speedtest Zeitüberschreitung'); + return; + } + attempts++; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetSpeedtestResult`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success && data.result) { + this.speedtestHistory.push(data.result); + this.$nextTick(() => { + if (this.$refs.speedtestBottom) { + this.$refs.speedtestBottom.scrollIntoView({ behavior: 'smooth' }); + } + }); + + if (data.result.bps > 0) this.speedtestHasStarted = true; + + if (this.speedtestHasStarted && data.result.bps === 0) { + this.speedtestLoading = false; + window.notify('success', 'Speedtest abgeschlossen'); + return; + } + } + } catch (e) { + console.error(e); + } + + if (this.speedtestLoading) setTimeout(poll, 1000); + }; + poll(); + }, + async runRemoteAccess(forceRecreate = false) { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showRemoteAccessModal = true; + this.remoteAccessLoading = true; + this.remoteAccessStep = forceRecreate ? 'Erstelle neue Zugangsdaten...' : 'Konfiguriere Zugriff...'; + this.remoteAccessResult = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRemoteAccess`, { + deviceId: this.routerDevice.deviceId, + forceRecreate: forceRecreate + }); + + if (data.success) { + this.remoteAccessResult = data; + if (forceRecreate) { + window.notify('success', 'Neue Zugangsdaten erstellt'); + } + } else { + throw new Error(data.message || "Unbekannter Fehler"); + } + } catch (error) { + window.notify('error', error.response?.data?.message || error.message || 'Fehler bei Remote Access'); + } finally { + this.remoteAccessLoading = false; + } + }, + async openNetworkStructure() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showNetworkStructureModal = true; + this.networkStructureLoading = true; + this.rootDevice = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsNetworkStructure`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.root) { + this.rootDevice = data.root; + } + } catch (error) { + console.error(error); + window.notify('error', 'Fehler beim Laden der Netzwerkstruktur'); + } finally { + this.networkStructureLoading = false; + } + }, + async openEventLog() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showEventLogModal = true; + this.eventLogLoading = true; + this.eventLogData = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsEventLog`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success && data.events) { + this.eventLogData = data.events; + } else { + throw new Error(data.message || "Keine Ereignisse gefunden"); + } + } catch (error) { + console.error(error); + window.notify('error', error.response?.data?.message || 'Fehler beim Laden des Ereignisprotokolls'); + } finally { + this.eventLogLoading = false; + } + } + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-router-manager', RadiusRouterManager); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusTransferModal.js b/public/js/pages/Radius/RadiusTransferModal.js new file mode 100644 index 000000000..1d7e62d22 --- /dev/null +++ b/public/js/pages/Radius/RadiusTransferModal.js @@ -0,0 +1,441 @@ +const RadiusTransferModal = { + name: 'RadiusTransferModal', + props: { + show: Boolean, + username: String + }, + template: ` + + + + + +
+
+ +
+ + +
+

+ Bitte geben Sie eine gültige E-Mail-Adresse ein. +

+
+
+ + +
+
+
+
+ `, + data: () => ({ + window: window, + transferInitialLoading: false, + transferMonthlyLoading: false, + transferYear: new Date().getFullYear(), + transferMonth: new Date().getMonth() + 1, + transferYearlyData: null, + transferMonthlyData: null, + transferChartInstance: null, + showYearDropdown: false, + + // Email Logic + showEmailModal: false, + isSendingEmail: false, + recipientEmail: '' + }), + computed: { + availableYears() { + const c = new Date().getFullYear(), s = 2021; + if (s > c) return [c]; + return Array.from({length: c - s + 1}, (_, i) => c - i); + }, + allMonths() { + return Array.from({length: 12}, (_, i) => ({ + month: i + 1, + name: new Date(2000, i, 1).toLocaleString('de-DE', {month: 'short'}) + })); + }, + isValidEmail() { + if (!this.recipientEmail) return false; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); + } + }, + watch: { + show(val) { + if (val && this.username) { + this.transferYear = new Date().getFullYear(); + this.transferMonth = new Date().getMonth() + 1; + this.fetchTransferYearData(); + } else { + // Cleanup + this.close(); + } + } + }, + methods: { + close() { + this.$emit('close'); + this.transferYearlyData = null; + this.transferMonthlyData = null; + this.showYearDropdown = false; + this.showEmailModal = false; + this.recipientEmail = ''; + this.isSendingEmail = false; + if (this.transferChartInstance) { + this.transferChartInstance.destroy(); + this.transferChartInstance = null; + } + }, + async fetchTransferYearData() { + this.transferInitialLoading = true; + this.transferYearlyData = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { + action2: 'transferStatistic', + username: this.username, + year: this.transferYear, + month: 0 + } + }); + if (data && data.monthlySummary) { + this.transferYearlyData = data; + const last = [...data.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); + this.transferMonth = last ? last.month : new Date().getMonth() + 1; + await this.fetchTransferMonthData(); + } else { + this.transferYearlyData = null; + } + } catch (e) { + console.error(e); + this.transferYearlyData = null; + } + this.transferInitialLoading = false; + }, + async fetchTransferMonthData() { + this.transferMonthlyLoading = true; + this.transferMonthlyData = null; + if (this.transferChartInstance) this.transferChartInstance.destroy(); + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { + action2: 'transferStatistic', + username: this.username, + year: this.transferYear, + month: this.transferMonth + } + }); + this.transferMonthlyData = data || null; + } catch (e) { + console.error(e); + this.transferMonthlyData = null; + } + this.transferMonthlyLoading = false; + this.$nextTick(() => { + if (this.show) this.renderTransferChart(); + }); + }, + isMonthDisabled(month) { + if (this.transferInitialLoading || this.transferMonthlyLoading) return true; + if (!this.transferYearlyData?.monthlySummary) return true; + const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); + return !m || m.grandTotalBytes === 0; + }, + selectYear(year) { + this.showYearDropdown = false; + if (this.transferYear !== year) this.changeTransferYear(year); + }, + async changeTransferYear(year) { + this.transferYear = year; + await this.fetchTransferYearData(); + }, + async changeTransferMonth(month) { + this.transferMonth = month; + await this.fetchTransferMonthData(); + }, + prepareEmailModal() { + if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; + this.recipientEmail = ''; + this.showEmailModal = true; + }, + async sendTransferEmail() { + if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; + this.isSendingEmail = true; + try { + const chartImageBase64 = this.transferChartInstance.toBase64Image(); + const payload = { + username: this.username, + year: this.transferYear, + month: this.transferMonth, + monthlySummary: this.transferMonthlyData.summary, + monthlyDetails: this.transferMonthlyData.details, + chartImage: chartImageBase64, + recipient: this.recipientEmail + }; + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, payload); + window.notify('success', 'E-Mail wurde erfolgreich versendet.'); + this.showEmailModal = false; + } catch (e) { + console.error("Failed to send transfer email:", e); + window.notify('error', 'Fehler beim Senden der E-Mail.'); + } finally { + this.isSendingEmail = false; + } + }, + processChartData(details) { + if (!details || !details.length) return {labels: [], datasets: []}; + const daily = details.reduce((a, s) => { + const d = s.startTime.split(' ')[0]; + if (!a[d]) a[d] = {downloadBytes: 0, uploadBytes: 0}; + a[d].downloadBytes += Number(s.downloadBytes) || 0; + a[d].uploadBytes += Number(s.uploadBytes) || 0; + return a; + }, {}); + const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); + return { + labels: dates, + datasets: [{ + label: 'Download', + data: dates.map(d => daily[d].downloadBytes), + borderColor: 'rgba(15, 157, 88, 0.8)', + backgroundColor: 'rgba(15, 157, 88, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }, { + label: 'Upload', + data: dates.map(d => daily[d].uploadBytes), + borderColor: 'rgba(0, 83, 132, 0.8)', + backgroundColor: 'rgba(0, 83, 132, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }] + }; + }, + renderTransferChart() { + if (this.transferChartInstance) this.transferChartInstance.destroy(); + if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; + const d = this.processChartData(this.transferMonthlyData.details); + if (!d.labels.length) return; + const chartBackgroundColorPlugin = { + id: 'customCanvasBackgroundColor', + beforeDraw: (chart) => { + const {ctx} = chart; + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, chart.width, chart.height); + ctx.restore(); + } + }; + this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { + type: 'line', + data: d, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: {unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: {day: 'DD.MM'}}, + grid: {display: false}, + ticks: {maxRotation: 0, autoSkip: true, maxTicksLimit: 15} + }, + y: { + beginAtZero: true, + ticks: {callback: (v) => window.TT_CORE.formatBytes(v, 0)}, + grid: {color: 'rgba(0,0,0,0.05)'} + } + }, + plugins: { + tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.TT_CORE.formatBytes(c.parsed.y)}`}}, + legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}} + }, + interaction: {mode: 'index', intersect: false} + }, + plugins: [chartBackgroundColorPlugin] + }); + } + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-transfer-modal', RadiusTransferModal); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusUnused.js b/public/js/pages/Radius/RadiusUnused.js index 463cb6d3f..a52bf45c9 100644 --- a/public/js/pages/Radius/RadiusUnused.js +++ b/public/js/pages/Radius/RadiusUnused.js @@ -1,15 +1,35 @@ -/* ===== RadiusUnused.js ===== */ -Vue.component('radius-unused-users', { +/* ===== RadiusUnused.js (Vue 3 + TT-Core) ===== */ + +const RadiusUnusedUsers = { + name: 'RadiusUnusedUsers', template: ` -
+
- +
- +
- + - - -