923 lines
41 KiB
JavaScript
923 lines
41 KiB
JavaScript
// Function to calculate similarity percentage between two strings
|
|
function calculateSimilarity(str1, str2) {
|
|
// Normalize strings by converting them to lowercase
|
|
str1 = str1.toLowerCase();
|
|
str2 = str2.toLowerCase();
|
|
|
|
let matchCount = 0;
|
|
|
|
// Check how many characters in str1 exist in str2
|
|
for (let char of str1) {
|
|
if (str2.includes(char)) {
|
|
matchCount++;
|
|
}
|
|
}
|
|
|
|
// Calculate similarity percentage
|
|
return (matchCount / str1.length) * 100;
|
|
}
|
|
|
|
function validateData(strasse, plz, stadt, info) {
|
|
const thresholds = 90; // Similarity threshold in percentage
|
|
|
|
// Validate each field against the info string
|
|
return !(calculateSimilarity(strasse, info) < thresholds ||
|
|
calculateSimilarity(plz, info) < thresholds ||
|
|
calculateSimilarity(stadt, info) < thresholds);
|
|
}
|
|
|
|
|
|
Vue.component('radius-ont-parser', {
|
|
template: `
|
|
<div class="container mt-4">
|
|
<!-- Step 1: File Upload -->
|
|
<div v-if="step === 1" class="card">
|
|
<div class="card-body">
|
|
<h4 class="card-title">Schritt 1: Excel (XLSX) Upload</h4>
|
|
<input type="file" class="form-control" @change="handleFileUpload" accept=".xlsx">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Column Mapping -->
|
|
<div v-if="step === 2" class="card mt-4">
|
|
<div class="card-body">
|
|
<h4 class="card-title">Schritt 2: Spaltenzuordnung</h4>
|
|
<div class="row">
|
|
<div class="col-md-6" v-for="(field, index) in requiredFields" :key="index">
|
|
<div class="form-group">
|
|
<label>{{ field.label }}</label>
|
|
<select class="form-control" v-model="selectedColumns[field.key]">
|
|
<option v-for="header in headers" :value="header">{{ header }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary mt-3" @click="startProcessing">Start Processing</button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Step 3: Result Download -->
|
|
<div v-if="step === 3" class="card mt-4">
|
|
<div class="card-body">
|
|
<h4 class="card-title">Schritt 3: Ergebnisse</h4>
|
|
<tt-button icon="fas fa-download" text="Neue Excel herunterladen" additional-class="btn-primary" @click="downloadResults"/>
|
|
|
|
<table class="table mt-4">
|
|
<thead>
|
|
<tr>
|
|
<template v-for="header in requiredFields">
|
|
<th>{{ header.label }}</th>
|
|
</template>
|
|
<th>ONT SN</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, index) in processedData" :key="index">
|
|
<td>{{ row[selectedColumns.kundennummer] }}</td>
|
|
<td>{{ row[selectedColumns.anschlussstrasse] }}</td>
|
|
<td>{{ row[selectedColumns.anschlussplz] }}</td>
|
|
<td>{{ row[selectedColumns.anschlusscity] }}</td>
|
|
<td>{{ row.ont_sn }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading Screen -->
|
|
<div v-if="loading" class="loading-overlay mt-4">
|
|
<div class="progress">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
:style="{ width: progress + '%' }">
|
|
{{ Math.round(progress) }}%
|
|
</div>
|
|
</div>
|
|
<div class="text-center mt-2">Processing {{ currentRow + 1 }} of {{ totalRows }}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
|
|
data() {
|
|
return {
|
|
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
|
|
};
|
|
},
|
|
|
|
methods: {
|
|
async handleFileUpload(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Load XLSX library dynamically
|
|
await this.loadXLSX();
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const data = new Uint8Array(e.target.result);
|
|
const workbook = XLSX.read(data, { type: "array" });
|
|
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
|
|
// Read entire sheet as rows of arrays (no header detection yet)
|
|
const allRows = XLSX.utils.sheet_to_json(worksheet, {
|
|
header: 1, // Return rows as arrays
|
|
blankrows: false // Skip blank rows
|
|
});
|
|
|
|
// If there's only one row or something unexpected, do a basic parse
|
|
if (allRows.length < 2) {
|
|
const fallbackData = XLSX.utils.sheet_to_json(worksheet);
|
|
this.parsedData = fallbackData;
|
|
this.headers = Object.keys(fallbackData[0] || {});
|
|
this.step = 2;
|
|
return;
|
|
}
|
|
|
|
const firstRow = allRows[0] || [];
|
|
const secondRow = allRows[1] || [];
|
|
|
|
// Count how many cells in each row are empty
|
|
const firstRowEmptyCount = firstRow.length - firstRow.filter(Boolean).length;
|
|
const secondRowEmptyCount = secondRow.length - secondRow.filter(Boolean).length;
|
|
|
|
// If the difference in empty cells is more than 25% of the number of columns in the second row,
|
|
// assume the first row is mostly empty and use the second row as the header
|
|
const useSecondRowAsHeader = (firstRowEmptyCount - secondRowEmptyCount) > 0.25 * secondRow.length;
|
|
|
|
// Now parse again with the correct header row
|
|
if (useSecondRowAsHeader) {
|
|
this.parsedData = XLSX.utils.sheet_to_json(worksheet, {
|
|
range: 1, // Start reading data after the first row
|
|
header: secondRow, // Use the second row as the header
|
|
defval: "" // Optional: fill empty cells with empty string
|
|
}).slice(1); // Skip the first row
|
|
this.headers = secondRow;
|
|
} else {
|
|
this.parsedData = XLSX.utils.sheet_to_json(worksheet, {
|
|
range: 0, // Start at the first row
|
|
header: firstRow, // Use the first row as the header
|
|
defval: ""
|
|
});
|
|
this.headers = firstRow;
|
|
}
|
|
|
|
this.step = 2;
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
},
|
|
|
|
|
|
|
|
async loadXLSX() {
|
|
if (!window.XLSX) {
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
},
|
|
|
|
async startProcessing() {
|
|
this.loading = true;
|
|
this.totalRows = this.parsedData.length;
|
|
const processedRows = [];
|
|
|
|
mainLoop:
|
|
for (let i = 0; i < this.parsedData.length; i++) {
|
|
this.currentRow = i;
|
|
this.progress = ((i + 1) / this.parsedData.length) * 100;
|
|
|
|
// Simulate processing
|
|
await this.sleep(100);
|
|
|
|
// Process row here
|
|
const row = this.parsedData[i];
|
|
|
|
const findUserResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?custnume=' + row[this.selectedColumns.kundennummer]);
|
|
const findUserData = await findUserResponse.json();
|
|
|
|
if (findUserData.length === 0) {
|
|
row.ont_sn = 'N/A - Kein Benutzer mit dieser Kundennummer gefunden';
|
|
processedRows.push(row);
|
|
} else if (findUserData.length === 1) {
|
|
const username = findUserData[0].username;
|
|
const radacctResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=' + username);
|
|
const radacctData = await radacctResponse.json();
|
|
|
|
row.ont_sn = radacctData.ont_sn || 'N/A - Keine ONT SN gefunden';
|
|
processedRows.push(row);
|
|
|
|
} else if (findUserData.length > 1) {
|
|
// check string simulairty of strasse, plz, stadt and atleast of 90% of each should be inside findUserData[].info
|
|
// if not, ont_sn = N/A - Anschluss konnte nicht zugeordnet werden
|
|
|
|
const strasse = row[this.selectedColumns.anschlussstrasse];
|
|
const plz = row[this.selectedColumns.anschlussplz];
|
|
const stadt = row[this.selectedColumns.anschlusscity];
|
|
const info = findUserData[0].info;
|
|
|
|
for (let user of findUserData) {
|
|
if (validateData(strasse, plz, stadt, info)) {
|
|
const username = user.username;
|
|
const radacctResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=' + username);
|
|
const radacctData = await radacctResponse.json();
|
|
|
|
row.ont_sn = radacctData.ont_sn || 'N/A - Keine ONT SN gefunden';
|
|
processedRows.push(row);
|
|
continue mainLoop;
|
|
}
|
|
}
|
|
|
|
row.ont_sn = 'N/A - Anschluss konnte nicht zugeordnet werden';
|
|
processedRows.push(row);
|
|
}
|
|
}
|
|
|
|
this.loading = false;
|
|
this.processedData = processedRows;
|
|
this.step = 3;
|
|
},
|
|
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");
|
|
},
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|
|
});
|
|
|
|
Vue.component('radius-online-state', {
|
|
props: ['username'],
|
|
template: `
|
|
<div ref="container">
|
|
<template v-if="data === null && observed">
|
|
<div class="d-flex justify-content-center align-items-center">
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
<span class="sr-only">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="data !== null">
|
|
<div :style="'border-radius: 10px;width: 110px;text-align: center;height:22px;background-color: ' + (data.online ? 'rgba(161,253,96,0.7)' : 'rgba(253,96,96,0.7)')">
|
|
<span>{{ data.ip }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
`,
|
|
data: () => ({
|
|
data: null,
|
|
observer: null,
|
|
observed: false
|
|
}),
|
|
mounted() {
|
|
this.observer = new IntersectionObserver(entries => {
|
|
if (entries[0].isIntersecting && !this.observed) {
|
|
this.observed = true;
|
|
this.fetchOnlineState();
|
|
}
|
|
}, { threshold: 0.1 });
|
|
|
|
this.observer.observe(this.$refs.container);
|
|
},
|
|
beforeDestroy() {
|
|
this.observer?.disconnect();
|
|
},
|
|
methods: {
|
|
async fetchOnlineState() {
|
|
const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${this.username}`);
|
|
if (response.ok) this.data = await response.json();
|
|
}
|
|
}
|
|
})
|
|
|
|
|
|
|
|
Vue.component('radius', {
|
|
template: `
|
|
<tt-card>
|
|
<template v-slot:header>
|
|
<h3>Radius</h3>
|
|
</template>
|
|
|
|
<div class="radius-view-selector">
|
|
<tt-button icon="fas fa-user" additional-class="btn-primary" text="Radius Benutzer" @click="view = 'radius'"/>
|
|
<tt-button icon="fas fa-user-plus" additional-class="btn-primary" text="Freie Radius Benutzer" @click="view = 'free'"/>
|
|
<tt-button text="Radius ONT Parser" icon="fas fa-cogs" additional-class="btn-primary" @click="view = 'ont'" v-show="window['TT_CONFIG']['CAN_BILLING'] === '1'"/>
|
|
<tt-button text="Radius ONT Reverse Parser" icon="fas fa-cogs" additional-class="btn-primary" @click="view = 'ontReverse'" v-show="window['TT_CONFIG']['CAN_BILLING'] === '1'"/>
|
|
</div>
|
|
|
|
<div v-if="view === 'radius'">
|
|
<div class="filters" @keyup.enter="loadRadiusUsers">
|
|
<tt-autocomplete sm :api-url="billAddrAutoCompleteUrl" label="Rechnungsadresse" v-model="custnum" ref="billAddr"/>
|
|
<tt-input sm label="Username" name="username" v-model="username"/>
|
|
<tt-input sm label="Info" name="info" v-model="info"/>
|
|
<tt-checkbox v-model="checkOnlineState" label="Online-Status abfragen" sm/>
|
|
<tt-button sm icon="fas fa-search" text="Suchen" additional-class="btn-primary" style="justify-self:center" @click="loadRadiusUsers"
|
|
:disabled="isLoading"/>
|
|
</div>
|
|
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Kundennummer</th>
|
|
<th>Username</th>
|
|
<th>Info</th>
|
|
<th>Status</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="user in radiusUsers" :key="user.id">
|
|
<td>
|
|
<a target="_blank" :href="window['TT_CONFIG']['BASE_PATH'] + '/Address?filter%5Bcustomer_number%5D=' + user.customerNumber">
|
|
{{ user.customerNumber }}
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<a target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + user.username">
|
|
{{ user.username }}
|
|
</a>
|
|
</td>
|
|
<td>{{ user.info }}</td>
|
|
<td>
|
|
<template v-if="checkOnlineState === 1">
|
|
<radius-online-state :username="user.username" :key="user.username + '_online_state_' + this.searchCount"/>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
<tt-button sm icon="fas fa-sync" text="Details" @click="fetchRadacctData(user.username)" additional-class="btn-primary"/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="view === 'free'" class="free-users-container">
|
|
<div class="free-users-column">
|
|
<h4>Freie NAT Benutzer ({{ freeNatUsers.length }})</h4>
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Info</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="user in freeNatUsers" :key="user.id">
|
|
<td><a target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + user.username">{{ user.username }}</a></td>
|
|
<td>{{ user.info }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="free-users-column">
|
|
<h4>Freie STF Benutzer ({{ freeStfUsers.length }})</h4>
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Info</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="user in freeStfUsers" :key="user.id">
|
|
<td><a target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + user.username">{{ user.username }}</a></td>
|
|
<td>{{ user.info }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="view === 'ont'">
|
|
<radius-ont-parser/>
|
|
</div>
|
|
|
|
<div v-if="view === 'ontReverse'">
|
|
<radius-ont-finder/>
|
|
</div>
|
|
|
|
|
|
<tt-modal :show.sync="showRadacctModal" title="Radacct Data" :save="false" :delete="false">
|
|
<div v-if="radacctData">
|
|
<table class="table">
|
|
<tr>
|
|
<th>Status:</th>
|
|
<td><span :class="['status-dot', radacctData.online ? 'online' : 'offline']"></span> {{ radacctData.online ? 'Online' : 'Offline' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>IP:</th>
|
|
<td>{{ radacctData.ip }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Username:</th>
|
|
<td><a target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username">{{ radacctData.username }}</a></td>
|
|
</tr>
|
|
<tr>
|
|
<th>Customer Number:</th>
|
|
<td>{{ radacctData.customerNumber }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Customer Name:</th>
|
|
<td>{{ radacctData.customerName }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Info:</th>
|
|
<td>{{ radacctData.info }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>WLAN Password:</th>
|
|
<td>{{ radacctData.wlanPassword }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Bandbreite:</th>
|
|
<td>{{ radacctData.actualBandwidth }}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</tt-modal>
|
|
</tt-card>
|
|
`,
|
|
data() {
|
|
return {
|
|
view: 'radius',
|
|
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
|
|
radiusUsers: [],
|
|
freeNatUsers: [],
|
|
freeStfUsers: [],
|
|
username: '',
|
|
info: '',
|
|
custnum: '',
|
|
window: window,
|
|
showRadacctModal: false,
|
|
checkOnlineState: 0,
|
|
radacctData: null,
|
|
isLoading: false,
|
|
searchCount: 0,
|
|
}
|
|
},
|
|
async mounted() {
|
|
console.log("hallo");
|
|
await this.loadFreeUsers();
|
|
},
|
|
methods: {
|
|
hideRadacctModal() {
|
|
this.showRadacctModal = false;
|
|
},
|
|
async loadRadiusUsers() {
|
|
this.isLoading = true;
|
|
this.radiusUsers = [];
|
|
let custnum = '';
|
|
if (this.$refs.billAddr.displayValue.length > 5) {
|
|
custnum = this.$refs.billAddr.displayValue.match(/\[(\d+)]/)[1];
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
username: this.username,
|
|
info: this.info,
|
|
custnum: custnum,
|
|
});
|
|
const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
|
|
if (response.ok) {
|
|
const users = await response.json()
|
|
if (users.length < 6) {
|
|
this.checkOnlineState = 1;
|
|
}
|
|
this.radiusUsers = users;
|
|
} else {
|
|
console.error('Failed to load radius users');
|
|
}
|
|
this.isLoading = false;
|
|
this.searchCount = this.searchCount + 1;
|
|
},
|
|
async fetchRadacctData(username) {
|
|
const params = new URLSearchParams({
|
|
action2: 'fetchRadacct',
|
|
username: username,
|
|
});
|
|
const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
|
|
if (response.ok) {
|
|
this.radacctData = await response.json();
|
|
this.showRadacctModal = true;
|
|
} else {
|
|
console.error('Failed to fetch radacct data');
|
|
}
|
|
},
|
|
async loadFreeUsers() {
|
|
try {
|
|
const natResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat');
|
|
const stfResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf');
|
|
|
|
if (natResponse.ok && stfResponse.ok) {
|
|
const natData = await natResponse.json();
|
|
const stfData = await stfResponse.json();
|
|
|
|
this.freeNatUsers = natData.users;
|
|
this.freeStfUsers = stfData.users;
|
|
} else {
|
|
console.error('Failed to load free users');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading free users:', error);
|
|
}
|
|
|
|
},
|
|
},
|
|
});
|
|
|
|
Vue.component('radius-ont-finder', {
|
|
template: `
|
|
<div class="container mt-4">
|
|
<div v-if="step === 1" class="card">
|
|
<div class="card-body">
|
|
<h4 class="card-title">Schritt 1: Excel (XLSX) Upload</h4>
|
|
<p>Bitte laden Sie eine XLSX-Datei hoch, die mindestens eine Spalte mit dem Header 'Serial' für die ONT-Seriennummern enthält. Erwartete Spalten (optional, aber zur Anzeige empfohlen): Nummer, Serial, ONT-Type, Meter, Pegel.</p>
|
|
<input type="file" class="form-control" @change="handleFileUpload" accept=".xlsx">
|
|
<div v-if="uploadError" class="alert alert-danger mt-3">{{ uploadError }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="step === 2" class="card mt-4">
|
|
<div class="card-body">
|
|
<h4 class="card-title">Schritt 2: Ergebnisse</h4>
|
|
<button class="btn btn-primary mb-3" @click="downloadResults">
|
|
<i class="fas fa-download mr-2"></i>Ergebnisse herunterladen
|
|
</button>
|
|
<button class="btn btn-secondary mb-3 ml-2" @click="resetComponent">
|
|
<i class="fas fa-redo mr-2"></i>Neue Datei hochladen
|
|
</button>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-bordered table-sm">
|
|
<thead class="thead-light">
|
|
<tr>
|
|
<th v-for="header in originalHeaders" :key="'orig-' + header">{{ header }}</th>
|
|
<th>Username</th>
|
|
<th>Kundennummer</th>
|
|
<th>Kundenname</th>
|
|
<th>Info</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, index) in processedData" :key="index">
|
|
<td v-for="header in originalHeaders" :key="'data-' + header + '-' + index">{{ row[header] }}</td>
|
|
<td>{{ row.fetched_username }}</td>
|
|
<td>{{ row.fetched_customerNumber }}</td>
|
|
<td>{{ row.fetched_customerName }}</td>
|
|
<td>{{ row.fetched_info }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading-overlay mt-4">
|
|
<div class="d-flex justify-content-center align-items-center flex-column">
|
|
<div class="spinner-border text-primary mb-3" role="status">
|
|
<span class="sr-only">Loading...</span>
|
|
</div>
|
|
<div class="progress w-75">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar"
|
|
:style="{ width: progress + '%' }"
|
|
:aria-valuenow="progress"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100">
|
|
{{ Math.round(progress) }}%
|
|
</div>
|
|
</div>
|
|
<div class="text-center mt-2">Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}</div>
|
|
<div v-if="currentSerial" class="text-center text-muted small mt-1">Aktuelle ONT SN: {{ currentSerial }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
|
|
data() {
|
|
return {
|
|
step: 1, // 1: Upload, 2: Results
|
|
parsedData: [], // Raw data from XLSX
|
|
processedData: [], // Data after API calls
|
|
originalHeaders: [], // Headers from the uploaded XLSX
|
|
loading: false,
|
|
progress: 0,
|
|
currentRow: 0,
|
|
totalRows: 0,
|
|
currentSerial: '', // Track the serial being processed for display
|
|
uploadError: null, // To display errors during upload/parsing
|
|
// Define the key column name expected in the XLSX for the ONT Serial Number
|
|
serialColumnName: 'Serial', // IMPORTANT: Adjust if the header name in the XLSX is different
|
|
// Define keys for the fetched data to avoid conflicts with original headers
|
|
fetchedKeys: {
|
|
username: 'fetched_username',
|
|
customerNumber: 'fetched_customerNumber',
|
|
customerName: 'fetched_customerName',
|
|
info: 'fetched_info'
|
|
},
|
|
// Base path for the API - ensure TT_CONFIG is available globally
|
|
apiBasePath: window.TT_CONFIG ? window.TT_CONFIG['BASE_PATH'] : '/default/path/to/api' // Provide a fallback or handle error if TT_CONFIG is missing
|
|
};
|
|
},
|
|
|
|
methods: {
|
|
/**
|
|
* Resets the component state to allow a new file upload.
|
|
*/
|
|
resetComponent() {
|
|
this.step = 1;
|
|
this.parsedData = [];
|
|
this.processedData = [];
|
|
this.originalHeaders = [];
|
|
this.loading = false;
|
|
this.progress = 0;
|
|
this.currentRow = 0;
|
|
this.totalRows = 0;
|
|
this.currentSerial = '';
|
|
this.uploadError = null;
|
|
// Reset the file input visually (optional, requires ref)
|
|
const input = this.$el.querySelector('input[type="file"]');
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handles the file input change event.
|
|
* Loads the XLSX library if needed, reads the file,
|
|
* parses the data, validates the 'Serial' column,
|
|
* and triggers processing.
|
|
* @param {Event} event - The file input change event.
|
|
*/
|
|
async handleFileUpload(event) {
|
|
const file = event.target.files[0];
|
|
this.uploadError = null; // Clear previous errors
|
|
if (!file) return;
|
|
|
|
this.loading = true; // Show loading indicator early
|
|
|
|
try {
|
|
// Load XLSX library dynamically if not already loaded
|
|
await this.loadXLSX();
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const data = new Uint8Array(e.target.result);
|
|
const workbook = XLSX.read(data, { type: "array" });
|
|
const worksheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[worksheetName];
|
|
|
|
// Parse the sheet into an array of objects, automatically detecting headers
|
|
this.parsedData = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); // Use defval to handle empty cells
|
|
|
|
if (this.parsedData.length === 0) {
|
|
this.uploadError = "Die hochgeladene Datei ist leer oder konnte nicht gelesen werden.";
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
|
|
// Get headers from the first row of parsed data
|
|
this.originalHeaders = Object.keys(this.parsedData[0]);
|
|
|
|
// --- Validation: Check if the required 'Serial' column exists ---
|
|
if (!this.originalHeaders.includes(this.serialColumnName)) {
|
|
this.uploadError = `Fehler: Die erforderliche Spalte '${this.serialColumnName}' wurde in der hochgeladenen Datei nicht gefunden. Gefundene Spalten: ${this.originalHeaders.join(', ')}`;
|
|
this.loading = false;
|
|
this.parsedData = []; // Clear data if invalid
|
|
this.originalHeaders = [];
|
|
// Keep step at 1 to show the error
|
|
return;
|
|
}
|
|
// --- End Validation ---
|
|
|
|
// If validation passes, proceed to processing
|
|
this.startProcessing();
|
|
|
|
} catch (parseError) {
|
|
console.error("Error parsing XLSX file:", parseError);
|
|
this.uploadError = `Fehler beim Verarbeiten der XLSX-Datei: ${parseError.message}`;
|
|
this.loading = false;
|
|
this.step = 1; // Stay on upload step
|
|
}
|
|
};
|
|
reader.onerror = (err) => {
|
|
console.error("FileReader error:", err);
|
|
this.uploadError = "Fehler beim Lesen der Datei.";
|
|
this.loading = false;
|
|
this.step = 1;
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
|
|
} catch (libLoadError) {
|
|
console.error("Error loading XLSX library:", libLoadError);
|
|
this.uploadError = "Fehler beim Laden der erforderlichen Bibliothek (xlsx). Bitte versuchen Sie es erneut.";
|
|
this.loading = false;
|
|
this.step = 1;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Dynamically loads the SheetJS (XLSX) library if it's not already available.
|
|
*/
|
|
async loadXLSX() {
|
|
if (!window.XLSX) {
|
|
console.log("Loading XLSX library...");
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'; // Consider using a newer version if available
|
|
script.async = true;
|
|
script.onload = () => {
|
|
console.log("XLSX library loaded.");
|
|
resolve();
|
|
};
|
|
script.onerror = (err) => {
|
|
console.error("Failed to load XLSX script:", err);
|
|
reject(new Error("Could not load XLSX library"));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Processes the parsed data row by row, fetching details from the Radius API.
|
|
*/
|
|
async startProcessing() {
|
|
this.loading = true;
|
|
this.totalRows = this.parsedData.length;
|
|
this.processedData = []; // Clear previous results
|
|
this.progress = 0;
|
|
this.currentRow = 0;
|
|
this.currentSerial = '';
|
|
|
|
const apiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=`;
|
|
const notFoundMessage = 'N/A - Keinen Benutzer mit dieser ONT SN gefunden';
|
|
|
|
for (let i = 0; i < this.parsedData.length; i++) {
|
|
this.currentRow = i;
|
|
const row = { ...this.parsedData[i] }; // Create a copy to avoid modifying original parsed data
|
|
const serialNumber = row[this.serialColumnName]?.trim(); // Get serial number, trim whitespace
|
|
|
|
this.currentSerial = serialNumber || 'Leer'; // Update display
|
|
this.progress = ((i + 1) / this.totalRows) * 100;
|
|
|
|
// Initialize fetched data fields
|
|
row[this.fetchedKeys.username] = '';
|
|
row[this.fetchedKeys.customerNumber] = '';
|
|
row[this.fetchedKeys.customerName] = '';
|
|
row[this.fetchedKeys.info] = '';
|
|
|
|
if (!serialNumber) {
|
|
// Handle rows with empty serial numbers
|
|
row[this.fetchedKeys.username] = 'N/A - Leere Seriennummer';
|
|
row[this.fetchedKeys.customerNumber] = 'N/A';
|
|
row[this.fetchedKeys.customerName] = 'N/A';
|
|
row[this.fetchedKeys.info] = 'N/A';
|
|
this.processedData.push(row);
|
|
await this.sleep(10); // Small delay for UI update even for empty rows
|
|
continue; // Move to the next row
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(apiUrlBase + encodeURIComponent(serialNumber));
|
|
if (!response.ok) {
|
|
// Handle HTTP errors (e.g., 404, 500)
|
|
console.error(`API Error for SN ${serialNumber}: ${response.status} ${response.statusText}`);
|
|
row[this.fetchedKeys.username] = `N/A - API Fehler (${response.status})`;
|
|
row[this.fetchedKeys.customerNumber] = 'N/A';
|
|
row[this.fetchedKeys.customerName] = 'N/A';
|
|
row[this.fetchedKeys.info] = 'N/A';
|
|
} else {
|
|
const data = await response.json();
|
|
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
// Assuming the first result is the relevant one if multiple are returned
|
|
const userData = data[0];
|
|
row[this.fetchedKeys.username] = userData.username || 'N/A';
|
|
row[this.fetchedKeys.customerNumber] = userData.customerNumber || 'N/A';
|
|
row[this.fetchedKeys.customerName] = userData.customerName || 'N/A';
|
|
row[this.fetchedKeys.info] = userData.info || 'N/A';
|
|
} else {
|
|
// Handle case where API returns success but an empty array or unexpected format
|
|
row[this.fetchedKeys.username] = notFoundMessage;
|
|
row[this.fetchedKeys.customerNumber] = 'N/A';
|
|
row[this.fetchedKeys.customerName] = 'N/A';
|
|
row[this.fetchedKeys.info] = 'N/A';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error fetching data for SN ${serialNumber}:`, error);
|
|
row[this.fetchedKeys.username] = 'N/A - Fehler bei API-Abfrage';
|
|
row[this.fetchedKeys.customerNumber] = 'N/A';
|
|
row[this.fetchedKeys.customerName] = 'N/A';
|
|
row[this.fetchedKeys.info] = 'N/A';
|
|
}
|
|
|
|
this.processedData.push(row);
|
|
|
|
// Optional small delay to prevent UI freeze on large files and allow progress update
|
|
if (i % 20 === 0) { // Update UI roughly every 20 rows
|
|
await this.sleep(20);
|
|
}
|
|
}
|
|
|
|
this.loading = false;
|
|
this.step = 2; // Move to results view
|
|
this.currentSerial = ''; // Clear serial display
|
|
},
|
|
|
|
/**
|
|
* Creates and triggers the download of an XLSX file containing the processed results.
|
|
*/
|
|
downloadResults() {
|
|
if (!this.processedData.length) return;
|
|
|
|
try {
|
|
// Prepare data for export: Select and order columns
|
|
const dataToExport = this.processedData.map(row => {
|
|
const exportRow = {};
|
|
// Include original columns first
|
|
this.originalHeaders.forEach(header => {
|
|
exportRow[header] = row[header];
|
|
});
|
|
// Add fetched data with user-friendly headers
|
|
exportRow['Username'] = row[this.fetchedKeys.username];
|
|
exportRow['Kundennummer'] = row[this.fetchedKeys.customerNumber];
|
|
exportRow['Kundenname'] = row[this.fetchedKeys.customerName];
|
|
exportRow['Info'] = row[this.fetchedKeys.info];
|
|
return exportRow;
|
|
});
|
|
|
|
|
|
const ws = XLSX.utils.json_to_sheet(dataToExport);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, "ONT_Finder_Results"); // Sheet name
|
|
|
|
// Generate filename (e.g., results_YYYYMMDD_HHMMSS.xlsx)
|
|
const timestamp = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 14);
|
|
const filename = `ont_finder_results_${timestamp}.xlsx`;
|
|
|
|
XLSX.writeFile(wb, filename);
|
|
} catch (error) {
|
|
console.error("Error generating results file:", error);
|
|
alert("Fehler beim Erstellen der Excel-Datei für den Download."); // Simple alert for user feedback
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Utility function to pause execution for a specified duration.
|
|
* Useful for allowing UI updates during long loops.
|
|
* @param {number} ms - Milliseconds to sleep.
|
|
*/
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Lifecycle hook called when the component is mounted.
|
|
* Checks if the global TT_CONFIG is available.
|
|
*/
|
|
mounted() {
|
|
if (!window.TT_CONFIG || !window.TT_CONFIG['BASE_PATH']) {
|
|
console.warn("Global TT_CONFIG or TT_CONFIG['BASE_PATH'] not found. API calls may fail. Using fallback path:", this.apiBasePath);
|
|
// Optionally display a warning to the user
|
|
// this.uploadError = "Konfiguration für API-Pfad nicht gefunden. Funktionalität möglicherweise beeinträchtigt.";
|
|
} else {
|
|
// Update apiBasePath if TT_CONFIG was found after initial data setup (less likely but safe)
|
|
this.apiBasePath = window.TT_CONFIG['BASE_PATH'];
|
|
}
|
|
// Ensure XLSX is loaded once the component is ready, in case it's needed immediately
|
|
// Although handleFileUpload loads it, pre-loading might be slightly smoother if needed elsewhere later
|
|
// this.loadXLSX().catch(err => console.error("Pre-loading XLSX library failed:", err));
|
|
}
|
|
});
|