From 167b038c2027e5082443917460391920ba744af4 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 9 Dec 2025 05:34:24 +0000 Subject: [PATCH 01/20] Radius/add network structure --- Layout/default/VueViews/Vue3.php | 133 ++ Layout/default/vueHeader3.php | 83 + application/Radius/RadiusController.php | 729 +++----- lib/GenieACS/GenieACS.php | 708 +++----- lib/Helper/Helper.php | 27 + public/js/pages/Radius/Radius.css | 495 +++--- public/js/pages/Radius/Radius.js | 436 +---- public/js/pages/Radius/RadiusFreeUsers.js | 167 +- public/js/pages/Radius/RadiusNetworkNode.js | 67 + public/js/pages/Radius/RadiusOntFinder.js | 210 ++- public/js/pages/Radius/RadiusOntParser.js | 195 ++- public/js/pages/Radius/RadiusRadacctModal.js | 85 + public/js/pages/Radius/RadiusRouterManager.js | 486 ++++++ public/js/pages/Radius/RadiusTransferModal.js | 441 +++++ public/js/pages/Radius/RadiusUnused.js | 174 +- public/js/pages/Radius/RadiusUsers.js | 1539 +++-------------- public/plugins/vue/tt-core/README.md | 551 ++++++ public/plugins/vue/tt-core/SUMMARY.md | 470 +++++ .../components/data-display/TtDataTable.js | 110 ++ .../components/data-display/TtStatusChip.js | 182 ++ .../tt-core/components/display/TtInfoCard.js | 51 + .../components/feedback/TtLoadingIndicator.js | 63 + .../tt-core/components/feedback/TtSkeleton.js | 50 + .../tt-core/components/forms/TtCopyButton.js | 46 + .../components/forms/TtFileDropzone.js | 105 ++ .../components/forms/TtSmartAutocomplete.js | 328 ++++ .../components/navigation/TtViewSwitcher.js | 71 + .../tt-core/components/overlays/TtDialog.js | 109 ++ .../vue/tt-core/composables/useAsyncData.js | 157 ++ .../tt-core/composables/useInfiniteScroll.js | 150 ++ .../composables/useIntersectionObserver.js | 100 ++ public/plugins/vue/tt-core/index.js | 93 + public/plugins/vue/tt-core/styles/tt-core.css | 943 ++++++++++ public/plugins/vue/tt-core/utils/clipboard.js | 32 + .../plugins/vue/tt-core/utils/formatting.js | 67 + .../vue/tt-core/utils/script-loader.js | 35 + .../plugins/vue/tt-core/utils/validation.js | 65 + 37 files changed, 6833 insertions(+), 2920 deletions(-) create mode 100644 Layout/default/VueViews/Vue3.php create mode 100644 Layout/default/vueHeader3.php create mode 100644 public/js/pages/Radius/RadiusNetworkNode.js create mode 100644 public/js/pages/Radius/RadiusRadacctModal.js create mode 100644 public/js/pages/Radius/RadiusRouterManager.js create mode 100644 public/js/pages/Radius/RadiusTransferModal.js create mode 100644 public/plugins/vue/tt-core/README.md create mode 100644 public/plugins/vue/tt-core/SUMMARY.md create mode 100644 public/plugins/vue/tt-core/components/data-display/TtDataTable.js create mode 100644 public/plugins/vue/tt-core/components/data-display/TtStatusChip.js create mode 100644 public/plugins/vue/tt-core/components/display/TtInfoCard.js create mode 100644 public/plugins/vue/tt-core/components/feedback/TtLoadingIndicator.js create mode 100644 public/plugins/vue/tt-core/components/feedback/TtSkeleton.js create mode 100644 public/plugins/vue/tt-core/components/forms/TtCopyButton.js create mode 100644 public/plugins/vue/tt-core/components/forms/TtFileDropzone.js create mode 100644 public/plugins/vue/tt-core/components/forms/TtSmartAutocomplete.js create mode 100644 public/plugins/vue/tt-core/components/navigation/TtViewSwitcher.js create mode 100644 public/plugins/vue/tt-core/components/overlays/TtDialog.js create mode 100644 public/plugins/vue/tt-core/composables/useAsyncData.js create mode 100644 public/plugins/vue/tt-core/composables/useInfiniteScroll.js create mode 100644 public/plugins/vue/tt-core/composables/useIntersectionObserver.js create mode 100644 public/plugins/vue/tt-core/index.js create mode 100644 public/plugins/vue/tt-core/styles/tt-core.css create mode 100644 public/plugins/vue/tt-core/utils/clipboard.js create mode 100644 public/plugins/vue/tt-core/utils/formatting.js create mode 100644 public/plugins/vue/tt-core/utils/script-loader.js create mode 100644 public/plugins/vue/tt-core/utils/validation.js diff --git a/Layout/default/VueViews/Vue3.php b/Layout/default/VueViews/Vue3.php new file mode 100644 index 000000000..e21333939 --- /dev/null +++ b/Layout/default/VueViews/Vue3.php @@ -0,0 +1,133 @@ + + +
+ <> +> +
+ + + + + + + + diff --git a/Layout/default/vueHeader3.php b/Layout/default/vueHeader3.php new file mode 100644 index 000000000..f4a126f50 --- /dev/null +++ b/Layout/default/vueHeader3.php @@ -0,0 +1,83 @@ + + + + + <?= MFAPPNAME_FULL ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index 12b12071a..1027fd221 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -16,12 +16,12 @@ class RadiusController extends mfBaseController { protected function indexAction() { $this->layout()->set('additionalJS', ["plugins/chart.js/chart.4.4.6.js", "plugins/chart.js/chartjs-adapter-moment.min.js"]); - + $allowedAcsUserIds = [9, 13, 25, 65, 135, 145, 178]; $acsEnabled = in_array($this->me->id, $allowedAcsUserIds); - Helper::renderVue($this, $this->mod, "Radius", [ - 'CAN_BILLING' => $this->me->can("Billing"), + Helper::renderVue3($this, $this->mod, "Radius", [ + 'CAN_BILLING' => $this->me->can("Billing"), 'HIDE_PAGE_TITLE' => true, 'USER_ID' => $this->me->id, 'ACS_ENABLED' => $acsEnabled @@ -29,6 +29,7 @@ class RadiusController extends mfBaseController { } protected function proxyUnsecureHTTPRequestToRadiusAction() { + $this->log->debug("proxyUnsecureHTTPRequestToRadiusAction", $_GET); $url = "http://radius.xinon.at/api.php?" . http_build_query($_GET); $url = str_replace("proxyUnsecureHTTPRequestToRadius", "", $url); $opts = [ @@ -44,21 +45,29 @@ class RadiusController extends mfBaseController { die(); } - /** - * Run speedtest via ACS proxy - */ protected function genieacsRunSpeedtestAction() { try { $input = json_decode(file_get_contents('php://input'), true); - $ip = $input['ip'] ?? null; + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRunSpeedtestAction", ['deviceId' => $deviceId]); - if (!$ip) { - self::sendError("IP address is required"); - } + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + + $acs->setParameterValues($deviceId, [ + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true + ]); + + $device = $acs->getDevice($deviceId); + $ip = GenieACS::getExternalIP($device); + + if (!$ip) self::sendError("Could not determine device IP"); $url = "http://acs.xinon.at:5000/run-speedtest"; $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; - $data = json_encode(['ip' => $ip]); $opts = [ @@ -74,332 +83,59 @@ class RadiusController extends mfBaseController { $context = stream_context_create($opts); $response = file_get_contents($url, false, $context); - if ($response === false) { - self::sendError("Failed to connect to speedtest server"); - } + if ($response === false) self::sendError("Failed to connect to speedtest server"); header("Content-Type: application/json"); echo $response; die(); } catch (Exception $e) { - error_log("GenieACS runSpeedtest error: " . $e->getMessage()); + $this->log->debug("Speedtest Error", ['error' => $e->getMessage()]); self::sendError("Error running speedtest: " . $e->getMessage()); } } - protected function sendCustomerEmailAction() { - $input = json_decode(file_get_contents('php://input'), true); - if (!$input || !isset($input['username'], $input['year'], $input['month'], $input['monthlySummary'], $input['monthlyDetails'], $input['recipient'])) - self::sendError("Ungültige oder unvollständige Eingabedaten."); - - $username = $input['username']; - $year = $input['year']; - $month = $input['month']; - $monthlySummary = $input['monthlySummary']; - $monthlyDetails = $input['monthlyDetails']; - $chartImage = $input['chartImage'] ?? ''; - $recipient = $input['recipient']; - - if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) - self::sendError("Ungültige E-Mail-Adresse des Empfängers."); - - $monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; - $monthName = $monthNames[$month - 1] ?? $month; - $subject = "Ihre Transfer-Statistik für {$monthName} {$year}"; - - // --- Helper Functions --- - $formatBytes = function($bytes, $precision = 2) { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - return round($bytes, $precision) . ' ' . $units[$pow]; - }; - - $formatDuration = function($seconds) { - $seconds = max(0, intval($seconds)); - if ($seconds === 0) return '00:00:00'; - $days = floor($seconds / 86400); - if ($days > 0) { - $remainder = $seconds % 86400; - $dayString = $days . ' ' . ($days == 1 ? 'Tag' : 'Tage'); - if ($remainder === 0) return $dayString; - return $dayString . ', ' . sprintf('%02d:%02d:%02d', floor($remainder / 3600), floor(($remainder % 3600) / 60), $remainder % 60); - } - return sprintf('%02d:%02d:%02d', floor($seconds / 3600), floor(($seconds % 3600) / 60), $seconds % 60); - }; - - // --- Data Preparation --- - $customerNumber = preg_replace('/[^0-9]/', '', $username) ?: 'Kunde'; - $logoToolPath = LIBDIR . '/../public/assets/images/the-tool-logo.png'; - $logoXinonPath = LIBDIR . '/../public/assets/images/xinon-full.png'; - $logoToolTag = file_exists($logoToolPath) ? 'TheTOOL Logo' : ''; - $logoXinonTag = file_exists($logoXinonPath) ? 'XINON Logo' : ''; - $currentYear = date("Y"); - $xinonBlue = '#005384'; - - // Monthly summary values - $monthlyTotal = $formatBytes($monthlySummary['grandTotalBytes']); - $monthlyDuration = $formatDuration($monthlySummary['totalDurationSeconds']); - $monthlyUpload = $formatBytes($monthlySummary['totalUploadBytes']); - $monthlyDownload = $formatBytes($monthlySummary['totalDownloadBytes']); - - // --- Daily Details Table Generation --- - $dailyDetailsTable = ''; - foreach ($monthlyDetails as $detail) { - $date = date("d.m.Y", strtotime($detail['startTime'])); - $dailyDetailsTable .= " - - {$date} - {$formatDuration($detail['durationSeconds'])} - {$formatBytes($detail['uploadBytes'])} - {$formatBytes($detail['downloadBytes'])} - {$formatBytes($detail['totalBytes'])} - "; - } - - // --- Base64 Encoded Icons (shortened as requested) --- - $icons = [ - 'total' => 'data:image/png;base64,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', - 'download' => 'data:image/png;base64,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', - 'upload' => 'data:image/png;base64,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', - 'duration' => 'data:image/png;base64,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', - ]; - - // --- Bulletproof HTML Email Template --- - $html = << - - - - - - {$subject} - - - - - - - -
- - - - - - - - - - -
- - - - - -
{$logoToolTag}{$logoXinonTag}
-
-

Ihre Transfer-Statistik

-

für {$monthName} {$year}

-

Sehr geehrter Kunde,
anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung.

- - - - - - - - - - - -
- - - - -
Gesamt
Monat gesamt
{$monthlyTotal}
-
- - - - -
Download
Download
{$monthlyDownload}
-
 
- - - - -
Upload
Upload
{$monthlyUpload}
-
- - - - -
Dauer
Dauer
{$monthlyDuration}
-
- -
Transfer Statistik Chart
- -

Tagesübersicht

- - - - - - - - - - - - {$dailyDetailsTable} - -
DatumDauerUploadDownloadGesamt
-

Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.

-
-

XINON GmbH

-

Fladnitz im Raabtal 150 | A-8322 Studenzen

-

- +43 3115 40800 | - office@xinon.at -

-

© {$currentYear} XINON GmbH | Impressum

-
-
- - -HTML; - $altBody = "Ihre Transfer-Statistik für {$monthName} {$year}\n\n" . - "Sehr geehrter Kunde,\n\n" . - "anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung für {$monthName} {$year}.\n\n" . - "Monatszusammenfassung:\n" . - "Gesamt: {$monthlyTotal}\n" . - "Download: {$monthlyDownload}\n" . - "Upload: {$monthlyUpload}\n" . - "Dauer: {$monthlyDuration}\n\n" . - "Eine detaillierte tägliche Aufstellung finden Sie in der HTML-Version dieser E-Mail.\n\n" . - "Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.\n\n" . - "© {$currentYear} XINON GmbH | Impressum: https://xinon.at/impressum/"; - - $mail = new PHPMailer(true); + protected function genieacsGetSpeedtestResultAction() { try { - $mail->isSMTP(); - $mail->Host = TT_PIPEWORK_SMTP_HOST; - $mail->SMTPAuth = true; - $mail->Username = TT_PIPEWORK_SMTP_USER; - $mail->Password = TT_PIPEWORK_SMTP_PASS; - $mail->CharSet = PHPMailer::CHARSET_UTF8; - $mail->Encoding = 'base64'; - $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; - $mail->Port = 587; + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsGetSpeedtestResultAction", ['deviceId' => $deviceId]); - if (file_exists($logoToolPath)) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); - if (file_exists($logoXinonPath)) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + if (!$deviceId) self::sendError("Device ID is required"); - if (!empty($chartImage) && ($imageData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $chartImage)))) { - $mail->addStringEmbeddedImage($imageData, 'chart_image', 'chart.png', 'base64', 'image/png'); - } + $acs = $this->getGenieACS(); + $value = $acs->getSpeedtestResult($deviceId); - $mail->setFrom('thetool@xinon.at', 'TheTOOL by XINON'); - $mail->addReplyTo('office@xinon.at', 'XINON Office'); - $mail->addAddress($recipient, $customerNumber); - $mail->isHTML(true); - $mail->Subject = $subject; - $mail->Body = $html; - $mail->AltBody = $altBody; - - $mail->send(); - self::returnJson(['success' => true, 'message' => 'Transfer-Statistik E-Mail wurde erfolgreich gesendet.']); + self::returnJson(['success' => true, 'value' => $value]); } catch (Exception $e) { - error_log("Mailer Error in sendCustomerEmailAction for username {$username}: " . $mail->ErrorInfo); - self::sendError("E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Support."); + $this->log->debug("Speedtest Result Error", ['error' => $e->getMessage()]); + self::sendError($e->getMessage()); } } - /** - * Get GenieACS instance - * @return GenieACS - */ private function getGenieACS() { $host = defined('GENIEACS_HOST') ? GENIEACS_HOST : 'http://acs.xinon.at:3000'; $username = defined('GENIEACS_USERNAME') ? GENIEACS_USERNAME : 'admin'; $password = defined('GENIEACS_PASSWORD') ? GENIEACS_PASSWORD : 'savemanfb545aw'; - return new GenieACS($host, $username, $password); } - /** - * Check if GenieACS task response indicates success - * @param mixed $result - * @return bool - */ - private static function isGenieAcsTaskSuccess($result) { - // Null or empty array can indicate success (204 No Content) - if ($result === null || (is_array($result) && empty($result))) { - return true; - } - - // Has explicit success flag - if (isset($result['success']) && $result['success']) { - return true; - } - - // Array of tasks with _id (task created successfully) - if (is_array($result)) { - // Check if it's an array of task objects - foreach ($result as $item) { - if (is_array($item) && isset($item['_id'])) { - return true; // Task was created - } - } - } - - return false; - } - - /** - * Get devices from GenieACS and match by IP - */ protected function genieacsGetDeviceByIpAction() { try { $ip = $_GET['ip'] ?? null; - if (!$ip) { - self::sendError("IP address is required"); - } + $this->log->debug("genieacsGetDeviceByIpAction", ['ip' => $ip]); + if (!$ip) self::sendError("IP address is required"); $acs = $this->getGenieACS(); $devices = $acs->getDevices(); - if (!$devices || !is_array($devices)) { + if (!$devices) { self::returnJson(['success' => false, 'message' => 'No devices found']); return; } - // Find device by matching external IP $matchedDevice = null; foreach ($devices as $device) { - $externalIp = GenieACS::getExternalIP($device); - if ($externalIp === $ip) { + if (GenieACS::getExternalIP($device) === $ip) { $matchedDevice = $device; break; } @@ -410,213 +146,312 @@ HTML; return; } - $deviceId = GenieACS::getDeviceId($matchedDevice); - $deviceInfo = GenieACS::getDeviceInfo($matchedDevice); - $managementIp = GenieACS::getManagementIP($matchedDevice); - self::returnJson([ 'success' => true, - 'deviceId' => $deviceId, - 'deviceInfo' => $deviceInfo, + 'deviceId' => GenieACS::getDeviceId($matchedDevice), + 'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice), 'ip' => $ip, - 'managementIp' => $managementIp + 'managementIp' => GenieACS::getManagementIP($matchedDevice) ]); } catch (Exception $e) { - error_log("GenieACS getDeviceByIp error: " . $e->getMessage()); + $this->log->debug("GetDeviceByIp Error", ['error' => $e->getMessage()]); self::sendError("Error fetching device: " . $e->getMessage()); } } - /** - * Reboot device via GenieACS - */ protected function genieacsRebootDeviceAction() { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRebootDeviceAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } - - error_log("GenieACS reboot request for device: " . $deviceId); + if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); - $result = $acs->rebootDevice($deviceId); + $acs->rebootDevice($deviceId); - error_log("GenieACS reboot result: " . json_encode($result)); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Reboot task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create reboot task', 'result' => $result]); - } + self::returnJson(['success' => true, 'message' => 'Reboot task created']); } catch (Exception $e) { - error_log("GenieACS rebootDevice error: " . $e->getMessage()); + $this->log->debug("Reboot Error", ['error' => $e->getMessage()]); self::sendError("Error rebooting device: " . $e->getMessage()); } } - /** - * Refresh device information - */ - protected function genieacsRefreshDeviceAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - - if (!$deviceId) { - self::sendError("Device ID is required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->refreshDevice($deviceId); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Refresh task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create refresh task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS refreshDevice error: " . $e->getMessage()); - self::sendError("Error refreshing device: " . $e->getMessage()); - } - } - - /** - * Get device parameters - */ - protected function genieacsGetParametersAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - $parameters = $input['parameters'] ?? []; - - if (!$deviceId || empty($parameters)) { - self::sendError("Device ID and parameters are required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->getParameterValues($deviceId, $parameters); - - // Check if result is a successful task response - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Get parameters task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create get parameters task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS getParameters error: " . $e->getMessage()); - self::sendError("Error getting parameters: " . $e->getMessage()); - } - } - - /** - * Set device parameters - */ - protected function genieacsSetParametersAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - $parameters = $input['parameters'] ?? []; - - if (!$deviceId || empty($parameters)) { - self::sendError("Device ID and parameters are required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->setParameterValues($deviceId, $parameters); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Set parameters task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create set parameters task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS setParameters error: " . $e->getMessage()); - self::sendError("Error setting parameters: " . $e->getMessage()); - } - } - - /** - * Get full device information - */ protected function genieacsGetDeviceInfoAction() { try { $deviceId = $_GET['deviceId'] ?? null; + $this->log->debug("genieacsGetDeviceInfoAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } + if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); $device = $acs->getDevice($deviceId); - if (!$device) { - self::sendError("Device not found"); - } - - $deviceInfo = GenieACS::getDeviceInfo($device); - $externalIp = GenieACS::getExternalIP($device); - $macAddress = GenieACS::getMacAddress($device); + if (!$device) self::sendError("Device not found"); self::returnJson([ 'success' => true, - 'deviceInfo' => $deviceInfo, - 'externalIp' => $externalIp, - 'macAddress' => $macAddress, + 'deviceInfo' => GenieACS::getDeviceInfo($device), + 'externalIp' => GenieACS::getExternalIP($device), + 'macAddress' => GenieACS::getMacAddress($device), 'fullData' => $device ]); } catch (Exception $e) { - error_log("GenieACS getDeviceInfo error: " . $e->getMessage()); + $this->log->debug("GetDeviceInfo Error", ['error' => $e->getMessage()]); self::sendError("Error getting device info: " . $e->getMessage()); } } - /** - * Ping an IP address via GenieACS - */ protected function genieacsPingAction() { try { $ip = $_GET['ip'] ?? null; + $this->log->debug("genieacsPingAction", ['ip' => $ip]); - if (!$ip) { - self::sendError("IP address is required"); - } + if (!$ip) self::sendError("IP address is required"); $acs = $this->getGenieACS(); $result = $acs->ping($ip); self::returnJson(['success' => true, 'result' => $result]); } catch (Exception $e) { - error_log("GenieACS ping error: " . $e->getMessage()); + $this->log->debug("Ping Error", ['error' => $e->getMessage()]); self::sendError("Error pinging: " . $e->getMessage()); } } - - /** - * Factory reset device via GenieACS - */ - protected function genieacsFactoryResetAction() { + + protected function genieacsRemoteAccessAction() { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRemoteAccessAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } - + if (!$deviceId) self::sendError("Device ID is required"); + $acs = $this->getGenieACS(); - $result = $acs->factoryResetDevice($deviceId); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Factory reset task created', 'result' => $result]); + $result = $acs->createRemoteUser($deviceId); + + if ($result) { + self::returnJson(['success' => true] + $result); } else { - self::returnJson(['success' => false, 'message' => 'Failed to create factory reset task', 'result' => $result]); + self::sendError("Could not retrieve TR069 username from device"); } } catch (Exception $e) { - error_log("GenieACS factoryReset error: " . $e->getMessage()); - self::sendError("Error factory resetting device: " . $e->getMessage()); + $this->log->debug("Remote Access Error", ['error' => $e->getMessage()]); + self::sendError("Error configuring remote access: " . $e->getMessage()); } } -} + + protected function genieacsNetworkStructureAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsNetworkStructureAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $creds = $acs->createRemoteUser($deviceId); + + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); + + $url = "http://acs.xinon.at:5000/read-fritz"; + $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; + + $data = json_encode([ + 'fritz_ip' => $creds['ip'], + 'fritz_port' => "9090", + 'fritz_user' => $creds['username'], + 'fritz_pass' => $creds['password'], + 'page' => 'netDev' + ]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 60 + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response) { + $json = json_decode($response, true); + // Check deeper structure: data -> data -> fbox/active + if ($json && isset($json['data']['data'])) { + $this->ensureMacDb(); + + $raw = $json['data']['data']; + $fbox = $raw['fbox'][0] ?? null; + $active = $raw['active'] ?? []; + + if (!$fbox) { + self::returnJson(['root' => null]); + return; + } + + // 2. Enrich active devices with Vendor and Initialize Children + foreach ($active as &$dev) { + $dev['children'] = []; + if (isset($dev['mac']) && $dev['mac']) { + $dev['vendor'] = $this->getVendor($dev['mac']); + } + } + unset($dev); + + // 3. Prepare Root + if (isset($fbox['mac']) && $fbox['mac']) { + $fbox['vendor'] = $this->getVendor($fbox['mac']); + } + $fbox['children'] = []; + $fbox['model'] = 'fbox'; + $fbox['name'] = $fbox['name'] ?? 'FRITZ!Box'; + $fboxIp = $fbox['ipv4']['ip'] ?? '192.168.178.1'; + + // 4. Map Active Devices by IP + $deviceMap = []; + foreach ($active as &$dev) { + if (isset($dev['ipv4']['ip']) && $dev['ipv4']['ip']) { + $deviceMap[$dev['ipv4']['ip']] = &$dev; + } + } + unset($dev); + + // 5. Build Tree + foreach ($active as &$dev) { + $parentIp = null; + // Attempt to extract IP from parent URL + if (!empty($dev['parent']['url'])) { + if (preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', urldecode($dev['parent']['url']), $matches)) { + $parentIp = $matches[1]; + } + } elseif (!empty($dev['parent']['name']) && $dev['parent']['name'] === 'fritz.repeater') { + // Fallback: if parent name is generic repeater but no URL/IP, strictly it's a child of root? + // But usually repeaters have IPs. If no IP found, attach to root. + } + + // Attach to parent if found, otherwise Root + if ($parentIp && isset($deviceMap[$parentIp]) && $parentIp !== $dev['ipv4']['ip']) { + $deviceMap[$parentIp]['children'][] = &$dev; + } else { + // Check if parent is Root (via IP or name) + // If parent IP is Root IP, or no parent found -> Root + $fbox['children'][] = &$dev; + } + } + unset($dev); + + // 6. Sort Function (Recursive) + $sortChildren = function(&$node) use (&$sortChildren) { + if (!empty($node['children'])) { + usort($node['children'], function($a, $b) { + // Ethernet First + $aType = strtolower($a['type'] ?? ''); + $bType = strtolower($b['type'] ?? ''); + + // Check for 'repeater' in name to prioritize repeaters/access points in sorting if needed + // User said: "middle-mans" (repeaters) then devices. + // Let's prioritize Repeaters/LAN Bridges. + $aIsRepeater = stripos($a['name'] ?? '', 'repeater') !== false; + $bIsRepeater = stripos($b['name'] ?? '', 'repeater') !== false; + + if ($aIsRepeater && !$bIsRepeater) return -1; + if (!$aIsRepeater && $bIsRepeater) return 1; + + if ($aType === 'ethernet' && $bType !== 'ethernet') return -1; + if ($aType !== 'ethernet' && $bType === 'ethernet') return 1; + + // Then by Name + return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); + }); + + foreach ($node['children'] as &$child) { + $sortChildren($child); + } + } + }; + + $sortChildren($fbox); + + self::returnJson(['root' => $fbox]); + } + } + + self::sendError("Failed to fetch network structure"); + } catch (Exception $e) { + $this->log->debug("Network Structure Error", ['error' => $e->getMessage()]); + self::sendError("Error: " . $e->getMessage()); + } + } + + + + private function ensureMacDb() { + + $path = TEMP_DIR . '/mac-vendors.csv'; + + if (!file_exists($path)) { + + $this->log->debug("Downloading MAC Vendor DB..."); + + $ctx = stream_context_create(['http'=> ['timeout' => 30]]); + + $data = @file_get_contents("https://maclookup.app/downloads/csv-database/get-db", false, $ctx); + + if ($data) file_put_contents($path, $data); + + } + + } + + + + private function getVendor($mac) { + + $mac = strtoupper(str_replace([':', '-', '.'], '', $mac)); + + if (strlen($mac) < 6) return null; + + + + $path = TEMP_DIR . '/mac-vendors.csv'; + + if (!file_exists($path)) return null; + + + + // Format as XX:XX:XX + + $prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2); + + + + // Use grep for speed if available, else fallback to basic search? + + // Assuming Linux env as per docker context. + + $cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path); + + $output = shell_exec($cmd); + + + + if ($output) { + + $parts = str_getcsv($output); + + if (isset($parts[1])) return $parts[1]; + + } + + return null; + + } + + } + + \ No newline at end of file diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php index 5688745e9..4f2b17ae8 100644 --- a/lib/GenieACS/GenieACS.php +++ b/lib/GenieACS/GenieACS.php @@ -13,476 +13,292 @@ class GenieACS { $this->baseurl = rtrim($baseurl, '/'); $this->username = $username; $this->password = $password; - - if (!$this->baseurl || !$this->username || !$this->password) { - throw new Exception("Invalid Arguments"); - } } - /** - * Authenticate and retrieve JWT token - * @return bool - * @throws Exception - */ private function _authenticate() { $session_key = "genieacs.{$this->baseurl}.jwt"; $session = new mfConfig($session_key); - // Check if we have a valid cached token (valid for 1 hour) if ($session->value() && (time() - $session->edit) < 3600) { $this->jwt_token = $session->value(); + $this->log->debug("GenieACS: Using cached JWT token."); return true; } - $url = $this->baseurl . '/login'; - - $ctx_options = [ + $this->log->debug("GenieACS: Authenticating to get new JWT token."); + $ctx = stream_context_create([ "http" => [ "ignore_errors" => true, "method" => "POST", - "header" => [ - "Accept: application/json, text/*", - "Content-Type: application/json; charset=UTF-8", - ], - "content" => json_encode([ - "username" => $this->username, - "password" => $this->password, - ]), + "header" => ["Content-Type: application/json"], + "content" => json_encode(["username" => $this->username, "password" => $this->password]), ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Extract JWT from response headers + ]); + + $response = file_get_contents($this->baseurl . '/login', false, $ctx); + if (isset($http_response_header)) { foreach ($http_response_header as $header) { - if (stripos($header, 'set-cookie') !== false && stripos($header, 'genieacs-ui-jwt=') !== false) { - preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches); - if (isset($matches[1])) { - $this->jwt_token = $matches[1]; - - // Cache the token - $session->value($this->jwt_token); - $session->save(); - - return true; - } + if (preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches)) { + $this->jwt_token = $matches[1]; + $session->value($this->jwt_token); + $session->save(); + $this->log->debug("GenieACS: Successfully retrieved and cached new JWT token."); + return true; } } } - - throw new Exception("Authentication failed - could not retrieve JWT token"); - } - - /** - * Make a GET request to the API - * @param string $endpoint - * @return array|null - * @throws Exception - */ - private function _get($endpoint) { - if (!$this->jwt_token) { - $this->_authenticate(); - } - - $url = $this->baseurl . $endpoint; - - $ctx_options = [ - 'http' => [ - 'ignore_errors' => true, - 'method' => 'GET', - 'header' => [ - 'Cookie: genieacs-ui-jwt=' . $this->jwt_token, - 'Accept: application/json', - ], - ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Check if we got a 401 and need to re-authenticate - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) { - // Token expired, re-authenticate and retry - $this->jwt_token = null; - $this->_authenticate(); - return $this->_get($endpoint); - } - } - } - - return json_decode($response, true); - } - - /** - * Make a POST request to the API - * @param string $endpoint - * @param array $data - * @return array|null - * @throws Exception - */ - private function _post($endpoint, $data) { - if (!$this->jwt_token) { - $this->_authenticate(); - } - - $url = $this->baseurl . $endpoint; - $jsonData = json_encode($data); - - $ctx_options = [ - 'http' => [ - 'ignore_errors' => true, - 'method' => 'POST', - 'header' => [ - 'Cookie: genieacs-ui-jwt=' . $this->jwt_token, - 'Accept: application/json', - 'Content-Type: application/json', - 'Content-Length: ' . strlen($jsonData) - ], - 'content' => $jsonData, - ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Log for debugging - error_log("GenieACS POST to $url: " . $jsonData); - error_log("GenieACS response: " . ($response ?: 'empty')); - - // Check if we got a 401 and need to re-authenticate - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - error_log("GenieACS response header: " . $header); - if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) { - // Token expired, re-authenticate and retry - $this->jwt_token = null; - $this->_authenticate(); - return $this->_post($endpoint, $data); - } - } - } - - // If response is empty or false, it might still be successful (204 No Content) - if ($response === false || $response === '') { - // Check if status code indicates success - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - if (stripos($header, 'HTTP/') === 0) { - if (stripos($header, '200') !== false || stripos($header, '202') !== false || stripos($header, '204') !== false) { - return ['success' => true]; - } - } - } - } - } - - return json_decode($response, true); - } - - /** - * Get all devices - * @return array|null - * @throws Exception - */ - public function getDevices() { - return $this->_get('/api/devices'); - } - - /** - * Get a specific device by ID - * @param string $deviceId Device ID (will be URL-encoded automatically) - * @return array|null - * @throws Exception - */ - public function getDevice($deviceId) { - return $this->_get('/api/devices/' . rawurlencode($deviceId)); - } - - /** - * Create a task for a device - * @param string $deviceId Device ID (will be URL-encoded automatically) - * @param array $tasks Array of tasks to execute - * @return array|null - * @throws Exception - * - * Example: - * $tasks = [ - * ["name" => "getParameterValues", "parameterNames" => ["InternetGatewayDevice.User.1.Username"]] - * ]; - */ - public function createTask($deviceId, $tasks) { - return $this->_post('/api/devices/' . rawurlencode($deviceId) . '/tasks', $tasks); - } - - /** - * Get parameter values from a device - * @param string $deviceId URL-encoded device ID - * @param array $parameterNames Array of parameter names to retrieve - * @return array|null - * @throws Exception - */ - public function getParameterValues($deviceId, $parameterNames) { - $tasks = [ - [ - "name" => "getParameterValues", - "parameterNames" => $parameterNames - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Set parameter values on a device - * @param string $deviceId URL-encoded device ID - * @param array $parameterValues Array of parameter name => value pairs - * @return array|null - * @throws Exception - */ - public function setParameterValues($deviceId, $parameterValues) { - // Convert associative array to GenieACS format: [["paramName", value, "type"], ...] - $formattedParams = []; - foreach ($parameterValues as $name => $value) { - // Determine XSD type based on value type - $xsdType = 'xsd:string'; // default - if (is_bool($value)) { - $xsdType = 'xsd:boolean'; - $value = $value ? true : false; // ensure proper boolean - } elseif (is_int($value)) { - $xsdType = 'xsd:int'; - } elseif (is_float($value)) { - $xsdType = 'xsd:double'; - } - - $formattedParams[] = [$name, $value, $xsdType]; - } - - $tasks = [ - [ - "name" => "setParameterValues", - "parameterValues" => $formattedParams - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Refresh device information - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function refreshDevice($deviceId) { - $tasks = [ - [ - "name" => "refreshObject", - "objectName" => "" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Reboot a device - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function rebootDevice($deviceId) { - $tasks = [ - [ - "name" => "reboot" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Factory reset a device - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function factoryResetDevice($deviceId) { - $tasks = [ - [ - "name" => "factoryReset" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Ping an IP address - * @param string $ip IP address to ping - * @return array|null - * @throws Exception - * - * Returns: { - * "packetsTransmitted": 3, - * "packetsReceived": 3, - * "packetLoss": 0, - * "min": 2.674, - * "avg": 3.054, - * "max": 3.34, - * "mdev": 0.28 - * } - */ - public function ping($ip) { - return $this->_get('/api/ping/' . $ip); - } - - /** - * Download a file from a device - * @param string $deviceId URL-encoded device ID - * @param string $fileType File type to download - * @param string $fileName Optional file name - * @return array|null - * @throws Exception - */ - public function downloadFile($deviceId, $fileType, $fileName = null) { - $task = [ - "name" => "download", - "fileType" => $fileType - ]; - - if ($fileName) { - $task["fileName"] = $fileName; - } - - return $this->createTask($deviceId, [$task]); - } - - /** - * Parse device data from API response to extract useful information - * @param array $deviceData Raw device data from API - * @return array Parsed device information - */ - public static function parseDeviceData($deviceData) { - $parsed = []; - - foreach ($deviceData as $key => $value) { - // Extract simple values - if (isset($value['value']) && is_array($value['value'])) { - $parsed[$key] = $value['value'][0]; - } - } - - return $parsed; - } - - /** - * Get device ID from device data - * @param array $deviceData Raw device data from API - * @return string|null Device ID - */ - public static function getDeviceId($deviceData) { - if (isset($deviceData['DeviceID.ID']['value'][0])) { - return $deviceData['DeviceID.ID']['value'][0]; - } - return null; - } - - /** - * Get MAC address from device data - * @param array $deviceData Raw device data from API - * @return string|null MAC address - */ - public static function getMacAddress($deviceData) { - // Try WAN connection MAC address first - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0]; - } - return null; - } - - /** - * Get external IP address from device data - * @param array $deviceData Raw device data from API - * @return string|null External IP address - */ - public static function getExternalIP($deviceData) { - // Try to get from WAN IP Connection - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0]; - } - - // Try alternative connection - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0]; - } - - return null; - } - - /** - * Get management/local IP address from device data (private IP) - * @param array $deviceData Raw device data from API - * @return string|null Management IP address - */ - public static function getManagementIP($deviceData) { - // Check both WAN connections and return the one with a private IP - $ips = []; - - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) { - $ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0]; - } - - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) { - $ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0]; - } - - // Return the first private IP found (10.x.x.x, 172.16-31.x.x, 192.168.x.x) - foreach ($ips as $ip) { - if (self::isPrivateIP($ip)) { - return $ip; - } - } - - // If no private IP found, return first IP - return $ips[0] ?? null; - } - - /** - * Check if an IP address is in a private range - * @param string $ip IP address - * @return bool True if private IP - */ - private static function isPrivateIP($ip) { - $parts = explode('.', $ip); - if (count($parts) !== 4) return false; - - $first = (int)$parts[0]; - $second = (int)$parts[1]; - - // 10.0.0.0 - 10.255.255.255 - if ($first === 10) return true; - - // 172.16.0.0 - 172.31.255.255 - if ($first === 172 && $second >= 16 && $second <= 31) return true; - - // 192.168.0.0 - 192.168.255.255 - if ($first === 192 && $second === 168) return true; - + $this->log->debug("GenieACS: Failed to retrieve JWT token."); return false; } - /** - * Get device manufacturer, model, and version info - * @param array $deviceData Raw device data from API - * @return array Device info - */ + private function _request($method, $endpoint, $data = null) { + if (!$this->jwt_token && !$this->_authenticate()) { + throw new Exception("GenieACS Authentication failed."); + } + + $this->log->debug("GenieACS: Making API request", ['method' => $method, 'endpoint' => $endpoint]); + $opts = [ + 'http' => [ + 'ignore_errors' => true, + 'method' => $method, + 'header' => ['Cookie: genieacs-ui-jwt=' . $this->jwt_token, 'Content-Type: application/json'], + ] + ]; + if ($data) $opts['http']['content'] = json_encode($data); + + $ctx = stream_context_create($opts); + $response = @file_get_contents($this->baseurl . $endpoint, false, $ctx); + + // Re-auth on 401 + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, '401') !== false) { + $this->log->debug("GenieACS: 401 Unauthorized, re-authenticating."); + $this->jwt_token = null; + if ($this->_authenticate()) { + return $this->_request($method, $endpoint, $data); + } else { + throw new Exception("GenieACS Re-authentication failed."); + } + } + } + } + + if ($response === false || $response === '') { + // 200-204 check + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, 'HTTP/') === 0 && (strpos($header, '200') !== false || strpos($header, '202') !== false || strpos($header, '204') !== false)) { + return ['success' => true]; + } + } + } + } + + $decoded = json_decode($response, true); + // If request was GET /devices/ID, the response IS the device object. + // If request was GET /devices, it is an array of objects. + return $decoded; + } + + public function getDevices() { + return $this->_request('GET', '/api/devices'); + } + + public function getDevice($deviceId) { + return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId)); + } + + public function rebootDevice($deviceId) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [['name' => 'reboot']]); + } + + public function ping($ip) { + return $this->_request('GET', '/api/ping/' . $ip); + } + + public function getParameterValues($deviceId, $parameterNames) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "getParameterValues", "parameterNames" => $parameterNames + ]]); + } + + public function setParameterValues($deviceId, $parameterValues) { + $formattedParams = []; + foreach ($parameterValues as $name => $value) { + $type = 'xsd:string'; + if (is_bool($value)) { $type = 'xsd:boolean'; $value = $value ? true : false; } + elseif (is_int($value)) $type = 'xsd:int'; + elseif (is_float($value)) $type = 'xsd:double'; + $formattedParams[] = [$name, $value, $type]; + } + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "setParameterValues", "parameterValues" => $formattedParams + ]]); + } + + public function getSpeedtestResult($deviceId) { + $param = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + $this->getParameterValues($deviceId, [$param]); + usleep(500000); + $device = $this->getDevice($deviceId); + return self::getParam($device, $param); + } + + public function createRemoteUser($deviceId) { + $this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId]); + $cacheKey = "remote_user_" . $deviceId; + if ($cached = $this->getCache($cacheKey)) { + $this->log->debug("GenieACS: Using cached credentials"); + return $cached; + } + + $password = $this->generatePassword(12); + $timestamp = (string)time(); + + $userParamsToRefresh = [ + 'InternetGatewayDevice.User.1.Enable', + 'InternetGatewayDevice.User.1.Password', + 'InternetGatewayDevice.User.1.RemoteAccessCapable', + 'InternetGatewayDevice.User.1.Username' + ]; + $this->getParameterValues($deviceId, $userParamsToRefresh); + sleep(2); + + $this->setParameterValues($deviceId, [ + 'InternetGatewayDevice.User.1.Enable' => true, + 'InternetGatewayDevice.User.1.Password' => $password, + 'InternetGatewayDevice.User.1.RemoteAccessCapable' => true, + 'InternetGatewayDevice.User.1.Username' => $timestamp + ]); + + // Poll for Username + $username = null; + $maxAttempts = 15; + $paramName = 'InternetGatewayDevice.User.1.Username'; + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep(1); + $this->getParameterValues($deviceId, [$paramName]); + usleep(500000); + + $device = $this->getDevice($deviceId); + + // Access property using flat dot-notation key + $val = self::getParam($device, $paramName); + $this->log->debug("GenieACS: Poll attempt " . ($i + 1) . " value: " . json_encode($val)); + + if ($val && strpos($val, 'TR069-') === 0) { + $username = $val; + break; + } + } + + if (!$username) { + $this->log->debug("GenieACS: Failed to retrieve TR069 username."); + return null; + } + + $ip = self::getExternalIP($this->getDevice($deviceId)); + if (!$ip) { + $this->log->debug("GenieACS: Could not get external IP."); + return null; + } + + $result = [ + 'username' => $username, + 'password' => $password, + 'ip' => $ip, + 'link' => "https://" . $ip . ":9090" + ]; + + $this->setCache($cacheKey, $result); + return $result; + } + + private function getCache($key) { + $file = TEMP_DIR . "/RadiusCache/" . md5($key) . ".json"; + if (file_exists($file)) { + if (filemtime($file) < (time() - 1800)) { + @unlink($file); + return null; + } + return json_decode(file_get_contents($file), true); + } + return null; + } + + private function setCache($key, $data) { + $dir = TEMP_DIR . "/RadiusCache/"; + if (!is_dir($dir)) @mkdir($dir, 0777, true); + file_put_contents($dir . md5($key) . ".json", json_encode($data)); + } + + private function generatePassword($length) { + $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return substr(str_shuffle(str_repeat($chars, ceil($length/strlen($chars)))), 1, $length); + } + + // Helpers to safely access device parameters from flat JSON structure + private static function getParam($deviceData, $key) { + if (!is_array($deviceData)) return null; + if (isset($deviceData[$key]['value'][0])) { + return $deviceData[$key]['value'][0]; + } + return null; + } + + public static function getExternalIP($deviceData) { + // Try typical WAN paths + $paths = [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress' + ]; + foreach ($paths as $path) { + $val = self::getParam($deviceData, $path); + if ($val) return $val; + } + return null; + } + + public static function getDeviceId($deviceData) { + return self::getParam($deviceData, 'DeviceID.ID'); + } + public static function getDeviceInfo($deviceData) { return [ - 'manufacturer' => $deviceData['DeviceID.Manufacturer']['value'][0] ?? null, - 'productClass' => $deviceData['DeviceID.ProductClass']['value'][0] ?? null, - 'oui' => $deviceData['DeviceID.OUI']['value'][0] ?? null, - 'serialNumber' => $deviceData['DeviceID.SerialNumber']['value'][0] ?? null, - 'hardwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.HardwareVersion']['value'][0] ?? null, - 'softwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.SoftwareVersion']['value'][0] ?? null, + 'serialNumber' => self::getParam($deviceData, 'DeviceID.SerialNumber'), + 'hardwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.HardwareVersion'), + 'softwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion'), ]; } -} + + public static function getManagementIP($deviceData) { + // Return any valid IP found, prioritizing private IPs if possible + $ip1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress'); + $ip2 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress'); + + if ($ip1 && self::isPrivateIP($ip1)) return $ip1; + if ($ip2 && self::isPrivateIP($ip2)) return $ip2; + return $ip1 ?: $ip2; + } + + public static function getMacAddress($deviceData) { + $mac1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress'); + if ($mac1) return $mac1; + return self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress'); + } + + private static function isPrivateIP($ip) { + $parts = explode('.', $ip); + if (count($parts) !== 4) return false; + $first = (int)$parts[0]; + $second = (int)$parts[1]; + if ($first === 10) return true; + if ($first === 172 && $second >= 16 && $second <= 31) return true; + if ($first === 192 && $second === 168) return true; + return false; + } +} \ No newline at end of file diff --git a/lib/Helper/Helper.php b/lib/Helper/Helper.php index cf013a7ce..8126e8990 100644 --- a/lib/Helper/Helper.php +++ b/lib/Helper/Helper.php @@ -165,6 +165,33 @@ class Helper { $controller->layout()->setTemplate("VueViews/Vue"); } + /** + * Displays Vue 3 component with the given header title. + * Uses TT-Core component library instead of legacy Vue 2 components. + * + * @param mfBaseController $controller The controller instance to generate $JSGlobals for. + * @param string $pageName The name of the Vue component to render. + * @param string $headerTitle The title to display in the header. + * @param array $additionalGlobals Additional global variables to pass to the Vue component. + */ + public static function renderVue3(mfBaseController $controller, string $pageName, string $headerTitle, array $additionalGlobals = []) { + $JSGlobals = ["BASE_URL" => $controller::getUrl($pageName), + "MF_URL" => $controller::getUrl(""), + "DASHBOARD_URL" => $controller::getUrl("Dashboard"), + "MF_APP_NAME" => MFAPPNAME_SLUG, + "BASE_PATH" => $controller::getUrl(""), + "PAGE_TITLE" => $headerTitle, + "PATH" => [["text" => MFAPPNAME_SLUG, "href" => $controller::getUrl("Dashboard")], + ["text" => $headerTitle, "href" => $controller::getUrl($pageName)]],]; + + $JSGlobals = array_merge($JSGlobals, $additionalGlobals); + + $controller->layout()->set("vueViewName", $pageName); + $controller->layout()->set("JSGlobals", $JSGlobals); + $controller->layout()->set("useVue3", true); // Flag to indicate Vue 3 mode + $controller->layout()->setTemplate("VueViews/Vue3"); + } + /** * Converts an array of objects to a CSV file. * @param array $rows The array of objects to convert to CSV. 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..6d0d6c87e --- /dev/null +++ b/public/js/pages/Radius/RadiusRouterManager.js @@ -0,0 +1,486 @@ +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.
+
+ +
+ `, + 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 + }), + 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 setParameterValues(parameters) { + if (!this.routerDevice || !this.routerDevice.deviceId || !parameters) return false; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsSetParameters`, { + deviceId: this.routerDevice.deviceId, + parameters: parameters + }); + return data.success; + } catch (error) { + console.error('Error setting parameters:', error); + return 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 params = { + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start': 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect': 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess': true + }; + + if (!await this.setParameterValues(params)) { + throw new Error("Konnte Speedtest-Parameter nicht setzen"); + } + + const ip = this.routerDevice.ip; + if (!ip) throw new Error("Keine IP-Adresse gefunden"); + + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRunSpeedtest`, { ip }); + + this.pollSpeedtestResult(); + + } catch (e) { + window.notify('error', e.message); + this.speedtestLoading = false; + } + }, + async pollSpeedtestResult() { + let attempts = 0; + const maxAttempts = 240; + const resultParam = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + + const poll = async () => { + if (!this.showSpeedtestModal) return; + if (attempts >= maxAttempts) { + this.speedtestLoading = false; + window.notify('error', 'Speedtest Zeitüberschreitung'); + return; + } + attempts++; + + try { + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetParameters`, { + deviceId: this.routerDevice.deviceId, + parameters: [resultParam] + }); + + setTimeout(async () => { + try { + const val = await this.fetchDeviceParameterValue(resultParam); + if (val && typeof val === 'string' && val.includes("BPS")) { + const parsed = this.parseSpeedtestResult(val); + if (parsed) { + this.speedtestHistory.push(parsed); + this.$nextTick(() => { + if (this.$refs.speedtestBottom) { + this.$refs.speedtestBottom.scrollIntoView({ behavior: 'smooth' }); + } + }); + + if (parsed.bps > 0) this.speedtestHasStarted = true; + + if (this.speedtestHasStarted && parsed.bps === 0) { + this.speedtestLoading = false; + window.notify('success', 'Speedtest abgeschlossen'); + return; + } + } + } + } catch(e) { console.error(e); } + + if (this.speedtestLoading) setTimeout(poll, 500); + }, 500); + + } catch (e) { + console.error(e); + if (this.speedtestLoading) setTimeout(poll, 500); + } + }; + poll(); + }, + async fetchDeviceParameterValue(paramName) { + if (!this.routerDevice || !this.routerDevice.deviceId) return null; + try { + const { data: deviceInfo } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceInfo`, { + params: { deviceId: this.routerDevice.deviceId } + }); + + if (deviceInfo?.success && deviceInfo?.fullData) { + const paramData = deviceInfo.fullData[paramName]; + if (paramData?.value?.[0]) { + return paramData.value[0]; + } + } + } catch (error) { + console.error('Error fetching parameter value:', error); + } + return null; + }, + formatBits(bps) { + if (!bps) return '0 Mbit/s'; + const mbits = bps / 1000000; + return mbits.toFixed(2) + ' Mbit/s'; + }, + parseSpeedtestResult(raw) { + try { + const bpsMatch = raw.match(/BPS\s+(\d+)/); + const bytesMatch = raw.match(/Bytes\s+(\d+)/); + const packetsMatch = raw.match(/Packets\s+(\d+)/); + + if (bpsMatch) { + const bps = parseInt(bpsMatch[1]); + const bytes = bytesMatch ? parseInt(bytesMatch[1]) : 0; + const packets = packetsMatch ? parseInt(packetsMatch[1]) : 0; + + return { + raw: raw, + bps: bps, + bpsFormatted: this.formatBits(bps), + bytes: bytes, + bytesFormatted: window.TT_CORE.formatBytes(bytes), + packets: packets + }; + } + } catch (e) { + console.error("Error parsing speedtest result", e); + } + return null; + }, + async runRemoteAccess() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showRemoteAccessModal = true; + this.remoteAccessLoading = true; + this.remoteAccessStep = 'Konfiguriere Zugriff...'; + this.remoteAccessResult = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRemoteAccess`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success) { + this.remoteAccessResult = data; + } 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; + } + } + } +}; + +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: ` -
+
- +
- +
- + - - - - + `, - 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/WarehouseShippingNote/WarehouseShippingNote.js b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js index 19cede786..9dc3a6b3d 100644 --- a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js @@ -44,6 +44,12 @@ window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [ "key": "print", "title": "Drucken", "class": "fas fa-print text-primary", + }, + { + "key": "createManualInvoice", + "title": "Rechnung erstellen", + "class": "fas fa-file-invoice text-primary", + "condition": (row) => row.status === 'accepted', } ] @@ -547,6 +553,7 @@ Vue.component('warehouse-shipping-note', { @status_to_cancelled="changeStatus($event.id, 'cancelled')" @status_to_new="changeStatus($event.id, 'new')" @add_log="addLogModalId = $event.id" + @createManualInvoice="createManualInvoice($event)" @edit="shippingNoteModalId = $event.id" ref="table"> @@ -678,6 +685,30 @@ Vue.component('warehouse-shipping-note', { this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten'); } }, + async createManualInvoice(row) { + try { + // Fetch shipping note with enriched article data + const res = await axios.get( + `${window.TT_CONFIG.BASE_PATH}/WarehouseShippingNote/getShippingNoteForInvoice`, + { params: { id: row.id } } + ); + + if (!res.data.success) { + window.notify('error', res.data.message || 'Fehler beim Laden der Lieferscheindaten'); + return; + } + + // Store in localStorage as array (to match WarehouseOrder pattern) + localStorage.setItem('ManualInvoice_create', JSON.stringify([res.data.data])); + + // Navigate to ManualInvoice module + window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice`; + + } catch (error) { + console.error('Error creating manual invoice:', error); + window.notify('error', 'Fehler beim Erstellen der Rechnung'); + } + }, } }) From 4731b874244c9b893125d2b4e69733d58f2479c2 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 9 Dec 2025 12:36:14 +0100 Subject: [PATCH 04/20] fixed query --- .../PreorderIFrame/PreorderIFrameModel.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/application/PreorderIFrame/PreorderIFrameModel.php b/application/PreorderIFrame/PreorderIFrameModel.php index aa85d31cf..8b0a6804d 100644 --- a/application/PreorderIFrame/PreorderIFrameModel.php +++ b/application/PreorderIFrame/PreorderIFrameModel.php @@ -10,14 +10,18 @@ class PreorderIFrameModel extends mfBaseModel public function getClusters($frame_referrer): array { $query = " - SELECT n.adb_netzgebiet_id as id, ng.name, pc.id as campaign_id, pc.name as campaign_name - FROM thetool.Preordercampaign pc - JOIN thetool.Network n ON pc.Network_id = n.id - JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id - WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL - GROUP BY n.adb_netzgebiet_id, ng.name - ORDER BY ng.name ASC - "; + SELECT + n.adb_netzgebiet_id as id, + ng.name, + GROUP_CONCAT(pc.id SEPARATOR ', ') as campaign_ids, + GROUP_CONCAT(pc.name SEPARATOR ', ') as campaign_names + FROM thetool.Preordercampaign pc + JOIN thetool.Network n ON pc.Network_id = n.id + JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id + WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL + GROUP BY n.adb_netzgebiet_id, ng.name + ORDER BY ng.name ASC + "; $res = $this->db->query($query); $clusters = $this->db->fetch_all_assoc($res); From f65b7492ec0dfe407e875ef7f63621621bf76015 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 10 Dec 2025 08:56:10 +0100 Subject: [PATCH 05/20] added displaying if a contact exists --- Layout/default/AddressDB/View.php | 11 ++++++++++- Layout/default/Preorder/Index.php | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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/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()): ?> From 0be058953aaca39055de18ee0d31d830337e4745 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 10 Dec 2025 09:07:16 +0100 Subject: [PATCH 06/20] fixed export warnings/errors and when no campaign is selected for admins --- Layout/default/Preorder/export.csv.php | 4 +-- application/Preorder/PreorderController.php | 39 ++++++++++++--------- application/Preorder/PreorderModel.php | 12 +++---- 3 files changed, 30 insertions(+), 25 deletions(-) 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/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 6e6e16c80..c36ae68d4 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -1001,34 +1001,38 @@ class PreorderController extends mfBaseController { $campaign_ids = []; foreach(PreordercampaignModel::search(["network_id" => $netzgebiet_ids]) as $campaign) { - echo "campaign: ".$campaign->id."
"; if(!in_array($campaign->id, $campaign_ids)) { $campaign_ids[] = $campaign->id; } } - if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) { - $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + if($this->me->is("Admin")) { + if(array_key_exists("preordercampaign_id", $filter) && $filter['preordercampaign_id']) { + $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + } } else { - $preorder_filter["preordercampaign_id"] = $campaign_ids; - } + if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) { + $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + } else { + $preorder_filter["preordercampaign_id"] = $campaign_ids; + } - if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) { - $campaign_id = $preorder_filter['preordercampaign_id']; - if(is_numeric($campaign_id) && $campaign_id > 0) { - $campaign = new Preordercampaign($campaign_id); - $this->layout()->set("campaign", $campaign); + if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) { + $campaign_id = $preorder_filter['preordercampaign_id']; + if(is_numeric($campaign_id) && $campaign_id > 0) { + $campaign = new Preordercampaign($campaign_id); + $this->layout()->set("campaign", $campaign); - if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { + if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { + $preorder_filter["operator_id"] = $this->me->address_id; + } + } + } else { + $preorder_filter['preordercampaign_id'] = $campaign_ids; + if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { $preorder_filter["operator_id"] = $this->me->address_id; } } - } else { - $preorder_filter['preordercampaign_id'] = $campaign_ids; - if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { - $preorder_filter["operator_id"] = $this->me->address_id; - } - } //$preorder_filter['layout()->setTemplate("Preorder/export.csv"); $this->layout()->set("res", $res); + $this->layout()->set("no_filename", false); } protected function apiAction() { diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index 59668b851..bfe320679 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -563,13 +563,13 @@ class PreorderModel mfLoghandler::singleton()->debug($sql); $res = $db->query($sql); + + // hack for Preorder::exportAction + if ($returnDBRessource) { + return $res; + } + if ($db->num_rows($res)) { - - // hack for Preorder::exportAction - if ($returnDBRessource) { - return $res; - } - while ($data = $db->fetch_object($res)) { if ($returnArray) { $items[] = $data; From 3fbf66365605c2a7adec9b75677c0c8e9f4bf504 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 10 Dec 2025 09:27:26 +0100 Subject: [PATCH 07/20] updated dashboard to use preordermodel calculation --- .../DashboardNew/DashboardNewController.php | 41 +--------- application/Preorder/PreorderModel.php | 77 ++++++------------- 2 files changed, 25 insertions(+), 93 deletions(-) diff --git a/application/DashboardNew/DashboardNewController.php b/application/DashboardNew/DashboardNewController.php index c3bc888c4..8fde56738 100644 --- a/application/DashboardNew/DashboardNewController.php +++ b/application/DashboardNew/DashboardNewController.php @@ -196,7 +196,7 @@ class DashboardNewController extends mfBaseController { $campaign_ids = array_map(fn($campaign) => $campaign->id, $owner_campaigns); } - $order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids); + $order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count']; $efh_connection_types = ["single-dwelling", "business"]; $mph_connection_types = ["apartment-building", "apartment", "multi-dwelling"]; @@ -370,7 +370,7 @@ class DashboardNewController extends mfBaseController { $campaign_ids = [$campaign->id]; $gemeinde_ids = []; // Empty array as in original - $order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids); + $order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count']; $efh_connection_types = [0, 1]; // Single-dwelling and business $mph_connection_types = [2]; // Apartment-building, apartment, multi-dwelling @@ -568,43 +568,6 @@ class DashboardNewController extends mfBaseController { return $timeline; } - private function getTotalHomes(array $preordercampaign_id = [], array $gemeinde_id = []) { - $baseSQL = "SELECT COUNT(adb_wohneinheit.id) as cnt FROM `" . ADDRESSDB_DBNAME . "`.Wohneinheit adb_wohneinheit - LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Hausnummer adb_hausnummer ON (adb_wohneinheit.hausnummer_id = adb_hausnummer.id) - LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Strasse adb_strasse ON (adb_hausnummer.strasse_id = adb_strasse.id) - WHERE 1=1"; - - $where = ""; - - if (!empty($preordercampaign_id)) { - $netzgebiet_ids = []; - foreach ($preordercampaign_id as $campaign_id) { - $campaign = new Preordercampaign($campaign_id); - if ($campaign->network_id) { - $network = new Network($campaign->network_id); - $netzgebiet_ids[] = $network->adb_netzgebiet_id; - } - } - - $where .= " AND adb_hausnummer.netzgebiet_id IN (" . implode(',', array_map('intval', $netzgebiet_ids)) . ")"; - } - - if (!empty($gemeinde_id)) { - $where .= " AND adb_strasse.gemeinde_id IN (" . implode(',', array_map('intval', $gemeinde_id)) . ")"; - } - - $sql = $baseSQL . $where; - - $res = $this->db()->query($sql); - if ($this->db()->num_rows($res)) { - $data = $this->db()->fetch_object($res); - return $data->cnt; - } - return 0; - } - - - protected function getDashboardAddressDBDataAction() { if (!$this->me->is("Admin")) self::sendError("Keine Berechtigung"); $baseFilter = []; diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index bfe320679..ac5b621b4 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -1261,46 +1261,34 @@ class PreorderModel ]; } - public static function countTotalUnits($preorderCampaignId = null) { + public static function countTotalUnits($preorderCampaignId = null, $gemeindeId = null) { $db = FronkDB::singleton(); + $where = ["1=1"]; - // The new WHERE condition is more complex and implemented directly in the main query. - $where = "1=1"; + // Support both array and single campaign ID if ($preorderCampaignId) { - $where .= " AND pc.id = " . (int)$preorderCampaignId; + $campaignIds = is_array($preorderCampaignId) ? array_map('intval', $preorderCampaignId) : [(int)$preorderCampaignId]; + $where[] = "pc.id IN (" . implode(',', $campaignIds) . ")"; } - // This query now implements the conditional logic for counting units. - // A unit is counted if its building type is standard, OR if its type is special AND has an active preorder. - $sql = "SELECT - pc.id AS campaign_id, - - -- Total unit count based on the new logic + if ($gemeindeId) { + $gemeindeIds = is_array($gemeindeId) ? array_map('intval', $gemeindeId) : [(int)$gemeindeId]; + $where[] = "s.gemeinde_id IN (" . implode(',', $gemeindeIds) . ")"; + } + + $whereClause = implode(' AND ', $where); + + $sql = "SELECT COUNT(w.id) AS total_unit_count, - - -- SD unit count (Single Dwelling) - SUM(CASE - WHEN h.tool_building_type IN (0, 1) THEN 1 - ELSE 0 - END) AS total_unit_count_sd, - - -- MD unit count (Multi Dwelling) - SUM(CASE - WHEN h.tool_building_type = 2 THEN 1 - ELSE 0 - END) AS total_unit_count_md, - - -- NEW Not2Connect unit count - SUM(CASE - WHEN h.rimo_op_state = 'Not2Connect' THEN 1 - ELSE 0 - END) AS total_unit_count_not2connect + SUM(CASE WHEN h.tool_building_type IN (0, 1) THEN 1 ELSE 0 END) AS total_unit_count_sd, + SUM(CASE WHEN h.tool_building_type = 2 THEN 1 ELSE 0 END) AS total_unit_count_md, + SUM(CASE WHEN h.rimo_op_state = 'Not2Connect' THEN 1 ELSE 0 END) AS total_unit_count_not2connect FROM `".FRONKDB_DBNAME."`.Preordercampaign pc LEFT JOIN `".FRONKDB_DBNAME."`.PreordercampaignSalescluster pcs ON pc.id = pcs.preordercampaign_id LEFT JOIN `".ADDRESSDB_DBNAME."`.Netzgebiet n ON pcs.salescluster_id = n.id LEFT JOIN `".ADDRESSDB_DBNAME."`.Hausnummer h ON n.id = h.netzgebiet_id + LEFT JOIN `".ADDRESSDB_DBNAME."`.Strasse s ON h.strasse_id = s.id LEFT JOIN `".ADDRESSDB_DBNAME."`.Wohneinheit w ON h.id = w.hausnummer_id - -- Subquery to find all buildings that have at least one active preorder LEFT JOIN ( SELECT p_sub.adb_hausnummer_id FROM `".FRONKDB_DBNAME."`.Preorder p_sub @@ -1308,26 +1296,12 @@ class PreorderModel WHERE p_sub.deleted = 0 AND ps_sub.code NOT IN (20) AND ps_sub.code < 899 GROUP BY p_sub.adb_hausnummer_id ) AS active_preorders ON h.id = active_preorders.adb_hausnummer_id - WHERE - ($where) - AND - ( - -- Condition 1: Include unit if its building rimo_type is NOT one of the special types. - h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet') - - OR - - -- Condition 2: OR if the rimo_type IS special (or NULL), include it ONLY IF an active preorder exists for the building. - ( - (h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')) - AND active_preorders.adb_hausnummer_id IS NOT NULL - ) - ) - GROUP BY pc.id"; + WHERE ($whereClause) + AND (h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet') + OR ((h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')) + AND active_preorders.adb_hausnummer_id IS NOT NULL))"; - $queryStart = microtime(true); $res = $db->query($sql); - mfLoghandler::singleton()->debug("[Query took: ".(microtime(true) - $queryStart)." seconds] " . $sql); if ($db->num_rows($res)) { $data = $db->fetch_object($res); @@ -1335,16 +1309,11 @@ class PreorderModel 'total_unit_count' => (int)$data->total_unit_count, 'total_unit_count_sd' => (int)$data->total_unit_count_sd, 'total_unit_count_md' => (int)$data->total_unit_count_md, - 'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect // New return value + 'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect ]; } - return [ - 'total_unit_count' => 0, - 'total_unit_count_sd' => 0, - 'total_unit_count_md' => 0, - 'total_unit_count_not2connect' => 0 - ]; + return ['total_unit_count' => 0, 'total_unit_count_sd' => 0, 'total_unit_count_md' => 0, 'total_unit_count_not2connect' => 0]; } public static function countHistoryStatus($filter = [], $status_code = null) { From b4af23139cd22938e0e49a3f285d88408d1518bb Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 10 Dec 2025 10:30:00 +0100 Subject: [PATCH 08/20] fixed php errors/warnings in frontend --- Layout/default/Order/Index.php | 54 ++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 25 deletions(-) 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; } } From 00f49cb946212c8cc3bf50fb029abdaa4b93c720 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 10 Dec 2025 09:52:05 +0000 Subject: [PATCH 09/20] Cpeprovisioning/add setup box --- .../CpeprovisioningController.php | 18 ++++++++++++++++-- .../pages/Cpeprovisioning/Cpeprovisioning.css | 15 +++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php index beec047a9..f66ed1e8d 100644 --- a/application/Cpeprovisioning/CpeprovisioningController.php +++ b/application/Cpeprovisioning/CpeprovisioningController.php @@ -451,8 +451,22 @@ class CpeprovisioningController extends mfBaseController { $attrs = $prod->product->attributes ?? []; if (empty($attrs) || !is_array($attrs)) continue; - if ($attrs['hw_only']->value ?? false) $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name; - if ($attrs['addon']->value ?? false) $orderInfo['hw'][] = $prod->product->name; + if ($attrs['bras_type']->value ?? false) continue; + + $added = false; + if ($attrs['hw_only']->value ?? false) { + $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name; + $added = true; + } + if ($attrs['addon']->value ?? false) { + $orderInfo['hw'][] = $prod->product->name; + $added = true; + } + + if (!$added && in_array($prod->product->productgroup_id, [6, 4, 8])) { + $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name; + } + if ($attrs['voip_chan']->value ?? false) $orderInfo['voip'] = true; if ($attrs['vot']->value ?? false) $orderInfo['vot'] = true; } 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 { From 955db848f11f7103918b42f67a5e2bd5925a49c2 Mon Sep 17 00:00:00 2001 From: Daniel Spitzer Date: Thu, 11 Dec 2025 18:50:14 +0100 Subject: [PATCH 10/20] Preorder Sync Script ins Qgis Achtung config erweitert QGIS_DBHOST usw. --- scripts/fiberplan/sync_preorder_qgis.php | 204 +++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 scripts/fiberplan/sync_preorder_qgis.php diff --git a/scripts/fiberplan/sync_preorder_qgis.php b/scripts/fiberplan/sync_preorder_qgis.php new file mode 100644 index 000000000..16f416cfc --- /dev/null +++ b/scripts/fiberplan/sync_preorder_qgis.php @@ -0,0 +1,204 @@ +me = new User(INTERNAL_USER_ID); + $this->layout()->setTemplate(null); + } + + public static function returnJson($data) { + self::$capturedResult = $data; + } +} + +$apiParams = [ + 'mod' => 'Preorder', + 'action' => 'api', + 'do' => 'getFilteredPreorders', + 'filter' => [ + 'preordercampaign_id' => 99 + ] +]; + +new PreorderSyncWrapper($apiParams); +$response = PreorderSyncWrapper::$capturedResult; + +if (!$response || !isset($response['status']) || $response['status'] !== 'OK') { + die("Fehler beim Abrufen der Daten oder keine Daten erhalten.\n"); +} + +$preorders = $response['result']['preorders'] ?? []; + +try { + $dsn = "pgsql:host=$pgHost;port=$pgPort;dbname=$pgDb"; + $pdo = new PDO($dsn, $pgUser, $pgPass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); +} catch (PDOException $e) { + die("Verbindung zu PostgreSQL fehlgeschlagen: " . $e->getMessage() . "\n"); +} + +$pdo->exec("CREATE SCHEMA IF NOT EXISTS $targetSchema"); + +$createTableSql = <<exec($createTableSql); + +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS company VARCHAR(255)"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS firstname VARCHAR(255)"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS lastname VARCHAR(255)"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS phone VARCHAR(100)"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS email VARCHAR(255)"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_code INTEGER"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_id INTEGER"); +$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS oaid VARCHAR(255)"); + +$sqlUpsert = <<prepare($sqlUpsert); + +$processedIds = []; +$countUpsert = 0; +$countUnchanged = 0; +$countSkipped = 0; + +$pdo->beginTransaction(); + +foreach ($preorders as $po) { + $id = $po->id; + $gps_lat = $po->gps_lat; + $gps_long = $po->gps_long; + + if (empty($gps_lat) || empty($gps_long)) { + $countSkipped++; + continue; + } + + $latVal = str_replace(',', '.', $gps_lat); + $lonVal = str_replace(',', '.', $gps_long); + + $params = [ + ':id' => $id, + ':type' => $po->type, + ':type_label' => $po->type_label, + ':strasse' => $po->adb_strasse, + ':hausnummer' => $po->adb_hausnummer, + ':plz' => $po->adb_plz, + ':ort' => $po->adb_ort, + ':company' => $po->company, + ':firstname' => $po->firstname, + ':lastname' => $po->lastname, + ':phone' => $po->phone, + ':email' => $po->email, + ':status_code' => $po->status_code, + ':status_id' => $po->status_id, + ':oaid' => $po->oaid, + ':lat' => $latVal, + ':lon' => $lonVal + ]; + + $stmt->execute($params); + $processedIds[] = $id; + if ($stmt->rowCount() > 0) { + $countUpsert++; + } else { + $countUnchanged++; + } +} + +$deletedCount = 0; +if (!empty($processedIds)) { + $inQuery = implode(',', array_map('intval', $processedIds)); + $deleteSql = "DELETE FROM $targetSchema.\"$targetTable\" WHERE id NOT IN ($inQuery)"; + $deletedCount = $pdo->exec($deleteSql); +} else { + + if (count($preorders) == 0) { + } +} + +$pdo->commit(); + +//echo "Sync fertig.\n"; +//echo "Neu erstellt oder aktualisiert: $countUpsert\n"; +//echo "Unverändert (kein Update nötig): $countUnchanged\n"; +//echo "Ohne Koordinaten (übersprungen): $countSkipped\n"; +//echo "Gelöscht (nicht mehr in Quelle): $deletedCount\n"; \ No newline at end of file From 7de86786334216e92d91eb96aad498d0ae7acaa2 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sat, 13 Dec 2025 11:05:39 +0000 Subject: [PATCH 11/20] updated findCities query --- .../PreorderIFrame/PreorderIFrameModel.php | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/application/PreorderIFrame/PreorderIFrameModel.php b/application/PreorderIFrame/PreorderIFrameModel.php index 8b0a6804d..6ae7780c0 100644 --- a/application/PreorderIFrame/PreorderIFrameModel.php +++ b/application/PreorderIFrame/PreorderIFrameModel.php @@ -36,27 +36,24 @@ class PreorderIFrameModel extends mfBaseModel public function findCities(array $params): array { - $whereClause = "p.plzstring = " . $this->db->escape($params['zip']); - if (!empty($params['gemeindeId'])) { - $whereClause .= " AND g.id = " . intval($params['gemeindeId']); - } elseif (!empty($params['clusterId'])) { - $whereClause .= " AND gn.netzgebiet_id = " . intval($params['clusterId']); - } else { - return []; // No identifier provided - } + if (empty($params['gemeindeId']) && empty($params['clusterId'])) return []; - $query = " - SELECT DISTINCT o.name - FROM addressdb.Plz p - JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id - JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id - LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id - WHERE $whereClause - ORDER BY o.name ASC - "; + $sql = "SELECT DISTINCT o.name FROM addressdb.Plz p + JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id + JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id + LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id + WHERE p.plzstring = " . $this->db->escape($params['zip']); - $res = $this->db->query($query); - return array_column($this->db->fetch_all_assoc($res), 'name'); + $cond = !empty($params['gemeindeId']) + ? " AND g.id = " . intval($params['gemeindeId']) + : " AND gn.netzgebiet_id = " . intval($params['clusterId']); + + $rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + if (empty($rows) && empty($params['gemeindeId'])) + $rows = $this->db->fetch_all_assoc($this->db->query($sql)); + + return array_column($rows, 'name'); } public function findStreets(array $params): array From 9f8bc2011d3a2d5bb0aa6f8148b6af7ad662bbd4 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sat, 13 Dec 2025 11:07:50 +0000 Subject: [PATCH 12/20] fixed findStreets query --- .../PreorderIFrame/PreorderIFrameModel.php | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/application/PreorderIFrame/PreorderIFrameModel.php b/application/PreorderIFrame/PreorderIFrameModel.php index 6ae7780c0..692c9a518 100644 --- a/application/PreorderIFrame/PreorderIFrameModel.php +++ b/application/PreorderIFrame/PreorderIFrameModel.php @@ -58,33 +58,29 @@ class PreorderIFrameModel extends mfBaseModel public function findStreets(array $params): array { - $whereClauses = []; - if (!empty($params['gemeindeId'])) { - $whereClauses[] = "g.id = " . intval($params['gemeindeId']); - } elseif (!empty($params['clusterId'])) { - $whereClauses[] = "gn.netzgebiet_id = " . intval($params['clusterId']); - } else { - return []; - } + if (empty($params['gemeindeId']) && empty($params['clusterId'])) return []; - $whereClauses[] = "o.name = '" . $this->db->escape($params['city']) . "'"; - $whereClauses[] = "EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)"; - $whereString = implode(" AND ", $whereClauses); + $sql = "SELECT DISTINCT s.name + FROM addressdb.Strasse s + JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id + JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id + LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id + WHERE o.name = '" . $this->db->escape($params['city']) . "' + AND EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)"; - $query = " - SELECT DISTINCT s.name - FROM addressdb.Strasse s - JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id - JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id - LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id - WHERE $whereString - ORDER BY s.name ASC - "; + $cond = !empty($params['gemeindeId']) + ? " AND g.id = " . intval($params['gemeindeId']) + : " AND gn.netzgebiet_id = " . intval($params['clusterId']); - $res = $this->db->query($query); - return array_column($this->db->fetch_all_assoc($res), 'name'); + $rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + // Fallback: If empty result and we were using clusterId, run without the specific ID constraint + if (empty($rows) && empty($params['gemeindeId'])) + $rows = $this->db->fetch_all_assoc($this->db->query($sql)); + + return array_column($rows, 'name'); } - + public function findAddresses(array $params): array { $whereClauses = [ From 1435923200d3f4209b885822c0032a6dff75b245 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sat, 13 Dec 2025 11:10:56 +0000 Subject: [PATCH 13/20] fixed findAddresses query --- .../PreorderIFrame/PreorderIFrameModel.php | 86 ++++++++----------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/application/PreorderIFrame/PreorderIFrameModel.php b/application/PreorderIFrame/PreorderIFrameModel.php index 692c9a518..a0c123881 100644 --- a/application/PreorderIFrame/PreorderIFrameModel.php +++ b/application/PreorderIFrame/PreorderIFrameModel.php @@ -80,73 +80,61 @@ class PreorderIFrameModel extends mfBaseModel return array_column($rows, 'name'); } - + public function findAddresses(array $params): array { - $whereClauses = [ - "p.plzstring = " . $this->db->escape($params['zip']), - "o.name = '" . $this->db->escape($params['city']) . "'", - "s.name = '" . $this->db->escape($params['street']) . "'", - "h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'", - ]; + if (empty($params['gemeinde_id']) && empty($params['cluster_id'])) return []; - if (!empty($params['gemeinde_id'])) { - $whereClauses[] = "h.gemeinde_id = " . intval($params['gemeinde_id']); - } elseif (!empty($params['cluster_id'])) { - $whereClauses[] = "h.netzgebiet_id = " . intval($params['cluster_id']); - } else { - return []; - } + $sql = "SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id, + h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type, + w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz + FROM addressdb.Hausnummer h + JOIN addressdb.Strasse s ON h.strasse_id = s.id + JOIN addressdb.Plz p ON h.plz_id = p.id + JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id + LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id + WHERE p.plzstring = " . $this->db->escape($params['zip']) . " + AND o.name = '" . $this->db->escape($params['city']) . "' + AND s.name = '" . $this->db->escape($params['street']) . "' + AND h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'"; - $whereString = implode(" AND ", $whereClauses); + $cond = !empty($params['gemeinde_id']) + ? " AND h.gemeinde_id = " . intval($params['gemeinde_id']) + : " AND h.netzgebiet_id = " . intval($params['cluster_id']); - $query = " - SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id, - h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type, - w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz - FROM addressdb.Hausnummer h - JOIN addressdb.Strasse s ON h.strasse_id = s.id - JOIN addressdb.Plz p ON h.plz_id = p.id - JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id - LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id - WHERE $whereString - "; + $results = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + if (empty($results) && empty($params['gemeinde_id'])) + $results = $this->db->fetch_all_assoc($this->db->query($sql)); - $results = $this->db->fetch_all_assoc($this->db->query($query)); if (empty($results)) return []; - $orderType = $params['orderType'] ?? 'order'; - - // For 'interest' order type, return a single entry for the whole building. - if ($orderType === 'interest') { - $representativeAddress = $this->formatAddressRow($results[0]); - $representativeAddress['wohneinheit_id'] = null; // Critical: No specific unit - $representativeAddress['oaid'] = $results[0]['oaid']; // Use building OAID - $representativeAddress['showText'] = "Gesamtes Gebäude"; - $representativeAddress['preorderTypes'] = ['interest']; - return [$representativeAddress]; // Return one item, so frontend proceeds directly. + if (($params['orderType'] ?? 'order') === 'interest') { + $addr = $this->formatAddressRow($results[0]); + $addr['wohneinheit_id'] = null; + $addr['oaid'] = $results[0]['oaid']; + $addr['showText'] = "Gesamtes Gebäude"; + $addr['preorderTypes'] = ['interest']; + return [$addr]; } - // Original logic for 'order' type $addresses = []; - $topCounter = 1; - if (count($results) > 1 && $results[0]['wohneinheit_id'] !== null) { + $i = 1; foreach ($results as $row) { - $address = $this->formatAddressRow($row); - $address['showText'] = $this->buildShowText($row, $topCounter++); - $address['preorderTypes'] = ['order']; - $addresses[] = $address; + $addr = $this->formatAddressRow($row); + $addr['showText'] = $this->buildShowText($row, $i++); + $addr['preorderTypes'] = ['order']; + $addresses[] = $addr; } } else { - // Single unit or building without units - $address = $this->formatAddressRow($results[0]); - $address['preorderTypes'] = ['order']; - $addresses[] = $address; + $addr = $this->formatAddressRow($results[0]); + $addr['preorderTypes'] = ['order']; + $addresses[] = $addr; } return $addresses; - } +} private function formatAddressRow(array $row): array { From 0755df54084ff16278d21fe0a94d6130d2f8bc3f Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sat, 13 Dec 2025 21:27:43 +0000 Subject: [PATCH 14/20] Workorder mph/improve --- Layout/default/menu.php | 2 + application/Radius/RadiusController.php | 73 +++++++-- .../WorkorderMphAdminController.php | 83 ++++++++-- .../WorkorderMphBaseController.php | 7 +- .../WorkorderMphCompanyController.php | 79 +++++++-- .../WorkorderTenantConfigModel.php | 2 + ...10120000_add_workorder_mph_permissions.php | 33 ++++ ...d_workorder_tenant_config_module_flags.php | 40 +++++ lib/GenieACS/GenieACS.php | 6 +- lib/Helper/Helper.php | 59 +++++++ public/js/pages/Radius/RadiusRouterManager.js | 77 ++++++++- public/js/pages/Radius/RadiusUsers.js | 2 +- .../WorkorderMphAdmin/WorkorderMphAdmin.js | 9 +- .../WorkorderMphBase/WorkorderMphBase.css | 19 +++ .../WorkorderMphBase/WorkorderMphBase.js | 14 +- .../WorkorderMphCompany.js | 152 +++++++++++------- .../WorkorderTenantConfig.js | 12 +- public/plugins/vue/tt-components/tt-table.js | 2 +- .../workorder-mph-create-from-hausnummer.php | 109 +++++++++++++ 19 files changed, 658 insertions(+), 122 deletions(-) create mode 100644 db/migrations/20251210120000_add_workorder_mph_permissions.php create mode 100644 db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php create mode 100644 scripts/workorder-mph-create-from-hausnummer.php diff --git a/Layout/default/menu.php b/Layout/default/menu.php index afd636c90..a3dfc4035 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -144,6 +144,8 @@ can("RMLCompany")): ?>
  • "> Arbeitsaufträge
  • can("RMLAdmin")): ?>
  • "> Arbeitsaufträge-Management
  • + can("WorkorderMph")): ?>
  • "> MPH Arbeitsaufträge
  • + can("WorkorderMphAdmin")): ?>
  • "> MPH Arbeitsaufträge Verwaltung
  • diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index 09ac74297..5af86bb65 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -17,14 +17,10 @@ class RadiusController extends mfBaseController { protected function indexAction() { $this->layout()->set('additionalJS', ["plugins/chart.js/chart.4.4.6.js", "plugins/chart.js/chartjs-adapter-moment.min.js"]); - $allowedAcsUserIds = [9, 13, 25, 65, 135, 145, 178]; - $acsEnabled = in_array($this->me->id, $allowedAcsUserIds); - Helper::renderVue3($this, $this->mod, "Radius", [ 'CAN_BILLING' => $this->me->can("Billing"), 'HIDE_PAGE_TITLE' => true, 'USER_ID' => $this->me->id, - 'ACS_ENABLED' => $acsEnabled ]); } @@ -286,13 +282,14 @@ class RadiusController extends mfBaseController { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; - $this->log->debug("genieacsRemoteAccessAction", ['deviceId' => $deviceId]); + $forceRecreate = $input['forceRecreate'] ?? false; + $this->log->debug("genieacsRemoteAccessAction", ['deviceId' => $deviceId, 'forceRecreate' => $forceRecreate]); if (!$deviceId) self::sendError("Device ID is required"); - + $acs = $this->getGenieACS(); - $result = $acs->createRemoteUser($deviceId); - + $result = $acs->createRemoteUser($deviceId, $forceRecreate); + if ($result) { self::returnJson(['success' => true] + $result); } else { @@ -304,19 +301,71 @@ class RadiusController extends mfBaseController { } } + protected function genieacsEventLogAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsEventLogAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $creds = $acs->createRemoteUser($deviceId); + + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); + + $url = "http://acs.xinon.at:5000/read-fritz-eventlog"; + $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; + + $data = json_encode([ + 'fritz_ip' => $creds['ip'], + 'fritz_port' => "9090", + 'fritz_user' => $creds['username'], + 'fritz_pass' => $creds['password'] + ]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 60 + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response) { + $json = json_decode($response, true); + if ($json && isset($json['data'])) { + self::returnJson(['success' => true, 'events' => $json['data']]); + return; + } + } + + self::sendError("Failed to fetch event log"); + } catch (Exception $e) { + $this->log->debug("Event Log Error", ['error' => $e->getMessage()]); + self::sendError("Error: " . $e->getMessage()); + } + } + protected function genieacsNetworkStructureAction() { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; $this->log->debug("genieacsNetworkStructureAction", ['deviceId' => $deviceId]); - + if (!$deviceId) self::sendError("Device ID is required"); - + $acs = $this->getGenieACS(); $creds = $acs->createRemoteUser($deviceId); - + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); - + $url = "http://acs.xinon.at:5000/read-fritz"; $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; diff --git a/application/WorkorderMphAdmin/WorkorderMphAdminController.php b/application/WorkorderMphAdmin/WorkorderMphAdminController.php index 1e7571a11..416d01d6d 100644 --- a/application/WorkorderMphAdmin/WorkorderMphAdminController.php +++ b/application/WorkorderMphAdmin/WorkorderMphAdminController.php @@ -8,11 +8,13 @@ class WorkorderMphAdminController extends WorkorderMphBaseController protected array $columns = [ ['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'netOwnerId', 'text' => 'Netzeigentümer', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false], 'required' => false], ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], - ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]], - ['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]], - ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]], - ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]], + ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]], + ['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]], ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], ]; @@ -21,11 +23,49 @@ class WorkorderMphAdminController extends WorkorderMphBaseController { $hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key')); array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]); + + // Handle netOwnerId column - only visible for admins + $netOwnerColIdx = array_search('netOwnerId', array_column($this->columns, 'key')); + if ($netOwnerColIdx !== false) { + if ($this->user->isAdmin()) { + $netOwners = Helper::getMphNetworkOwners(); + $this->columns[$netOwnerColIdx]['table']['filterOptions'] = array_map(fn($o) => ['value' => $o->id, 'text' => $o->company], $netOwners); + } else { + $this->columns[$netOwnerColIdx]['table'] = false; + } + } + + // Populate netzgebiet filter options + $netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key')); + if ($netzgebietColIdx !== false) { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + // Apply network ownership filtering + $netzgebietFilter = ""; + if (!$this->user->isAdmin()) { + $allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user); + if (!empty($allowedNetzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds); + $netzgebietFilter = " AND ng.id IN (" . implode(',', $escapedIds) . ")"; + } + } + + $fronkDbName = FRONKDB_DBNAME; + $sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng + INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id + INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id + WHERE ng.name IS NOT NULL AND ng.name != '' + $netzgebietFilter + ORDER BY ng.name ASC"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete); + } } public function indexAction() { - $this->createWorkordersFromHausnummer(); + // Note: Workorder creation is now handled by cronjob script: scripts/workorder-mph-create-from-hausnummer.php parent::indexAction(); } @@ -41,6 +81,18 @@ class WorkorderMphAdminController extends WorkorderMphBaseController $whereClauses = "WHERE 1=1"; + // Apply network ownership filtering (similar to WorkorderAdmin) + if (!$this->user->isAdmin()) { + $allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user); + if (!empty($allowedNetzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds); + $whereClauses .= " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")"; + } else { + // User has no networks assigned, show no results + $whereClauses .= " AND 1=0"; + } + } + if (empty($filters['status'])) { $whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')"; } else { @@ -48,12 +100,15 @@ class WorkorderMphAdminController extends WorkorderMphBaseController } if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true); + if (!empty($filters['netOwnerId'])) $whereClauses .= Helper::generateFilterCondition($filters['netOwnerId'], 'n.owner_id'); if (!empty($filters['hausnummerInfo'])) { $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); } - if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.name'); + if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id'); + if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name'); if (!empty($filters['companyName'])) $whereClauses .= Helper::generateFilterCondition($filters['companyName'], 'c.name'); + if (!empty($filters['wohneinheitCount'])) $whereClauses .= Helper::generateFilterCondition($filters['wohneinheitCount'], '(SELECT COUNT(*) FROM `' . $addressDbName . '`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id)', true); if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); @@ -63,7 +118,9 @@ class WorkorderMphAdminController extends WorkorderMphBaseController IFNULL(c.name, 'Nicht zugewiesen') as companyName, CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, - IFNULL(ng.name, '-') as netzgebietName, + ng.id as netzgebietName, + n.owner_id as netOwnerId, + hn.rimo_fcp_name as rimoFcpName, (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount FROM `$fronkDbName`.`WorkorderMph` w LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id @@ -72,12 +129,13 @@ class WorkorderMphAdminController extends WorkorderMphBaseController LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id $whereClauses "; $orderBy = ""; if (!empty($order['key'])) { - $sortableColumns = ['id', 'status', 'deadlineDate', 'companyName', 'additionalInfo', 'appointmentDate', 'netzgebietName']; + $sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate', 'wohneinheitCount']; if (in_array($order['key'], $sortableColumns)) { $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; @@ -95,8 +153,9 @@ class WorkorderMphAdminController extends WorkorderMphBaseController LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id $whereClauses"; - $totalCount = $db->query($countSql)->fetch_assoc()['count']; + $totalCount = (int)$db->query($countSql)->fetch_assoc()['count']; // Add pagination if ($pagination['per_page'] !== null) { @@ -109,10 +168,10 @@ class WorkorderMphAdminController extends WorkorderMphBaseController self::returnJson([ 'rows' => $rows, 'pagination' => [ - 'page' => $pagination['page'], - 'per_page' => $pagination['per_page'], + 'page' => (int)$pagination['page'], + 'per_page' => (int)$pagination['per_page'], 'total_rows' => $totalCount, - 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'total_pages' => (int)ceil($totalCount / $pagination['per_page']), 'filtered_available' => $totalCount ] ]); diff --git a/application/WorkorderMphBase/WorkorderMphBaseController.php b/application/WorkorderMphBase/WorkorderMphBaseController.php index b78a02b08..0c9c7e25e 100644 --- a/application/WorkorderMphBase/WorkorderMphBaseController.php +++ b/application/WorkorderMphBase/WorkorderMphBaseController.php @@ -3,7 +3,7 @@ class WorkorderMphBaseController extends TTCrud { protected array $statusColumn = [ - 'key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [ + 'key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'sortable' => false, 'filterOptions' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], ['value' => 'scheduled', 'text' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'], @@ -523,7 +523,10 @@ class WorkorderMphBaseController extends TTCrud $newValue = $post[$field] ? 1 : 0; if ($oldValue !== $newValue) { $workorder->$field = $newValue; - $changes[] = "$fieldLabel: " . ($newValue ? 'ja' : 'nein'); + // Only log changes where newValue is 'ja' or oldValue was 'ja' (changing from yes to no) + if ($newValue === 1 || $oldValue === 1) { + $changes[] = "$fieldLabel: " . ($newValue ? 'ja' : 'nein'); + } // Check for FTTx Location mit Leerrohr versorgt if ($field === 'fttxLocationSupplied' && $newValue === 1) { diff --git a/application/WorkorderMphCompany/WorkorderMphCompanyController.php b/application/WorkorderMphCompany/WorkorderMphCompanyController.php index 6ba353c0a..6e52b88ca 100644 --- a/application/WorkorderMphCompany/WorkorderMphCompanyController.php +++ b/application/WorkorderMphCompany/WorkorderMphCompanyController.php @@ -7,10 +7,11 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController protected array $permissionCheck = ['RMLCompany']; protected array $columns = [ - ['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true]], - ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['sortable' => false]], - ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]], - ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]], + ['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]], + ['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]], ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], ]; @@ -23,6 +24,22 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); $this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0; + + // Populate netzgebiet filter options for this company's workorders + $netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key')); + if ($netzgebietColIdx !== false && $company) { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $fronkDbName = FRONKDB_DBNAME; + + $sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng + INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id + INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id + WHERE ng.name IS NOT NULL AND ng.name != '' AND wm.companyId = " . intval($company->id) . " + ORDER BY ng.name ASC"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete); + } } protected function getAction() @@ -54,6 +71,8 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); } + if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id'); + if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name'); if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); if (!empty($filters['appointmentDate'])) $whereClauses .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate'); if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); @@ -63,18 +82,21 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, + ng.id as netzgebietName, + hn.rimo_fcp_name as rimoFcpName, (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount FROM `$fronkDbName`.`WorkorderMph` w LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id $whereClauses "; $orderBy = ""; if (!empty($order['key'])) { - $sortableColumns = ['id', 'status', 'deadlineDate', 'additionalInfo', 'appointmentDate']; + $sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate']; if (in_array($order['key'], $sortableColumns)) { $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; @@ -90,8 +112,9 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id $whereClauses"; - $totalCount = $db->query($countSql)->fetch_assoc()['count']; + $totalCount = (int)$db->query($countSql)->fetch_assoc()['count']; // Add pagination if ($pagination['per_page'] !== null) { @@ -104,10 +127,10 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController self::returnJson([ 'rows' => $rows, 'pagination' => [ - 'page' => $pagination['page'], - 'per_page' => $pagination['per_page'], + 'page' => (int)$pagination['page'], + 'per_page' => (int)$pagination['per_page'], 'total_rows' => $totalCount, - 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'total_pages' => (int)ceil($totalCount / $pagination['per_page']), 'filtered_available' => $totalCount ] ]); @@ -190,14 +213,6 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController $workorder = WorkorderMphModel::get($this->postData['workorderId']); if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); - // Validate that all required Wohneinheiten have notes - $wohneinheiten = WorkorderMphWohneinheitModel::getAll(['workorderMphId' => $workorder->id]); - foreach ($wohneinheiten as $we) { - if (empty($we->note)) { - self::sendError("Bitte fügen Sie für jede Wohneinheit eine Notiz hinzu, bevor Sie den Auftrag abschließen."); - } - } - $oldStatus = $workorder->status; $workorder->status = 'documented'; WorkorderMphModel::update((array)$workorder); @@ -253,4 +268,34 @@ class WorkorderMphCompanyController extends WorkorderMphBaseController WorkorderMphDocumentationModel::delete($doc->id); self::returnJson(['success' => true, 'message' => 'Dokumentation gelöscht.']); } + + protected function updateAdditionalInfoAction() + { + if (empty($this->postData['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($this->postData['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + // Verify company access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company || $workorder->companyId != $company->id) { + self::sendError("Keine Berechtigung für diesen Arbeitsauftrag."); + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $this->postData['additionalInfo'] ?? ''; + $workorder->additionalInfo = $newInfo; + WorkorderMphModel::update((array)$workorder); + + if ($oldInfo !== $newInfo) { + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Notiz geändert: " . ($newInfo ?: '(leer)'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + self::returnJson(['success' => true, 'message' => 'Notiz aktualisiert.', 'newInfo' => $newInfo]); + } } diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php index 6454d6502..bc917678b 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php @@ -12,6 +12,8 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel { public int $civilEngineeringDocsRequired; public int $requireCableLength; public int $requireCableType; + public int $enableWorkorder; + public int $enableWorkorderMph; public int $create; public int $createBy; diff --git a/db/migrations/20251210120000_add_workorder_mph_permissions.php b/db/migrations/20251210120000_add_workorder_mph_permissions.php new file mode 100644 index 000000000..0e45ebc33 --- /dev/null +++ b/db/migrations/20251210120000_add_workorder_mph_permissions.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table("WorkerPermission"); + $table->addColumn("canWorkorderMphAdmin", "enum", ["values" => 'false,true', "default" => "false", "after" => "canRMLAdmin"]); + $table->addColumn("canWorkorderMph", "enum", ["values" => 'false,true', "default" => "false", "after" => "canWorkorderMphAdmin"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("WorkerPermission")->removeColumn("canWorkorderMphAdmin")->save(); + $this->table("WorkerPermission")->removeColumn("canWorkorderMph")->save(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php b/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php new file mode 100644 index 000000000..6933ce5d9 --- /dev/null +++ b/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php @@ -0,0 +1,40 @@ +getEnvironment() == "thetool") { + $table = $this->table('WorkorderTenantConfig'); + + $table->addColumn('enableWorkorder', 'boolean', [ + 'default' => true, + 'null' => false, + 'after' => 'requireCableType', + 'comment' => 'Enable Workorder module for this tenant' + ]); + + $table->addColumn('enableWorkorderMph', 'boolean', [ + 'default' => true, + 'null' => false, + 'after' => 'enableWorkorder', + 'comment' => 'Enable WorkorderMPH module for this tenant' + ]); + + $table->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WorkorderTenantConfig') + ->removeColumn('enableWorkorder') + ->removeColumn('enableWorkorderMph') + ->save(); + } + } +} \ No newline at end of file diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php index 4f2b17ae8..540026e90 100644 --- a/lib/GenieACS/GenieACS.php +++ b/lib/GenieACS/GenieACS.php @@ -146,10 +146,10 @@ class GenieACS { return self::getParam($device, $param); } - public function createRemoteUser($deviceId) { - $this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId]); + public function createRemoteUser($deviceId, $forceRecreate = false) { + $this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId, 'forceRecreate' => $forceRecreate]); $cacheKey = "remote_user_" . $deviceId; - if ($cached = $this->getCache($cacheKey)) { + if (!$forceRecreate && $cached = $this->getCache($cacheKey)) { $this->log->debug("GenieACS: Using cached credentials"); return $cached; } diff --git a/lib/Helper/Helper.php b/lib/Helper/Helper.php index 8126e8990..1bc28fc37 100644 --- a/lib/Helper/Helper.php +++ b/lib/Helper/Helper.php @@ -252,4 +252,63 @@ class Helper { return array_map(fn($owner) => new Address($owner['id']), $results); } + + /** + * Get AddressDB Netzgebiet IDs that a user has access to based on their Network ownership + * @param User $user The user to get networks for + * @return array Array of addressdb netzgebiet IDs + */ + public static function getADBNetworksFromUser($user): array { + if ($user->isAdmin()) { + // Admin has access to all networks + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $sql = "SELECT id FROM Netzgebiet WHERE id IS NOT NULL"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + return array_column($netzgebiete, 'id'); + } + + // Get networks where user's address is the owner + $networks = NetworkModel::search(['owner_id' => $user->address_id]); + + // Also check user flags for additional networks + $flagNetworkIds = json_decode($user->getFlag("workordermph_networks")->value() ?: '[]', true); + if (!empty($flagNetworkIds)) { + $additionalNetworks = NetworkModel::search(['id' => $flagNetworkIds]); + $networks = array_merge($networks, $additionalNetworks); + } + + // Extract adb_netzgebiet_id from networks + $netzgebietIds = []; + foreach ($networks as $network) { + if ($network->adb_netzgebiet_id) { + $netzgebietIds[] = $network->adb_netzgebiet_id; + } + } + + return array_unique(array_filter($netzgebietIds)); + } + + /** + * Get network owners that have WorkorderMph entries (based on Netzgebiet) + * @return array Array of Address objects representing network owners + */ + public static function getMphNetworkOwners(): array { + $db = FronkDB::singleton(); + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + $fronkDbName = FRONKDB_DBNAME; + + $sql = "SELECT DISTINCT a.id, a.company, a.lastname, a.firstname + FROM `$fronkDbName`.`WorkorderMph` wm + INNER JOIN `$addressDbName`.`Hausnummer` hn ON wm.hausnummerId = hn.id + INNER JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + INNER JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id + INNER JOIN `$fronkDbName`.`Address` a ON n.owner_id = a.id + WHERE a.id IS NOT NULL + ORDER BY a.company, a.lastname, a.firstname"; + + $results = $db->fetch_all_assoc($db->query($sql)) ?? []; + + return array_map(fn($owner) => new Address($owner['id']), $results); + } } \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusRouterManager.js b/public/js/pages/Radius/RadiusRouterManager.js index 04d63be4d..47289255a 100644 --- a/public/js/pages/Radius/RadiusRouterManager.js +++ b/public/js/pages/Radius/RadiusRouterManager.js @@ -71,6 +71,10 @@ const RadiusRouterManager = { Netzwerkstruktur +
    @@ -157,6 +161,12 @@ const RadiusRouterManager = {
    +
    + +
    Ein Fehler ist aufgetreten.
    @@ -173,6 +183,34 @@ const RadiusRouterManager = {
    Keine Daten verfügbar.
    + + + +
    +
    + + + + + + + + + + + + + + + + + +
    DatumUhrzeitGruppeNachricht
    {{ event.date }}{{ event.time }}{{ event.group }}{{ event.msg }}
    +
    +
    +
    Keine Ereignisse verfügbar.
    +
    +
    `, data: () => ({ @@ -197,7 +235,11 @@ const RadiusRouterManager = { showNetworkStructureModal: false, networkStructureLoading: false, - rootDevice: null + rootDevice: null, + + showEventLogModal: false, + eventLogLoading: false, + eventLogData: null }), watch: { show: { @@ -353,20 +395,24 @@ const RadiusRouterManager = { }; poll(); }, - async runRemoteAccess() { + async runRemoteAccess(forceRecreate = false) { if (!this.routerDevice || !this.routerDevice.deviceId) return; this.showRemoteAccessModal = true; this.remoteAccessLoading = true; - this.remoteAccessStep = 'Konfiguriere Zugriff...'; + 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 + 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"); } @@ -396,6 +442,29 @@ const RadiusRouterManager = { } 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; + } } } }; diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index 9c91cce9b..bc3e5d96a 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -150,7 +150,7 @@ const RadiusUsers = { data-tooltip-align="left"> - diff --git a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js index 44cff87af..72191b6e8 100644 --- a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js +++ b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js @@ -4,10 +4,7 @@ Vue.component('workorder-mph-admin', { @@ -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', From b4961fd4234b29762ffc895d61b6914999e9f07f Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sun, 14 Dec 2025 22:15:00 +0100 Subject: [PATCH 17/20] new network model and mfBaseModelV2 --- .../ConstructionConsentProject/Form.php | 25 +- Layout/default/Network/Form.php | 42 +- Layout/default/Preordercampaign/Form.php | 96 ++-- application/ADBNetzgebiet/ADBNetzgebiet.php | 196 ++++--- .../ADBNetzgebiet/ADBNetzgebietController.php | 137 +++++ .../ADBNetzgebiet/ADBNetzgebietModel.php | 188 +------ .../ConstructionConsentProject.php | 14 + .../20251214150000_create_journal_table.php | 34 ++ lib/mfBaseModelV2/README.md | 155 +++++ lib/mfBaseModelV2/mfBaseModelV2.php | 373 ++++++++++++ .../js/pages/ADBNetzgebiet/ADBNetzgebiet.css | 530 ++++++++++++++++++ .../js/pages/ADBNetzgebiet/ADBNetzgebiet.js | 489 ++++++++++++++++ 12 files changed, 1944 insertions(+), 335 deletions(-) create mode 100644 application/ADBNetzgebiet/ADBNetzgebietController.php create mode 100644 db/migrations/20251214150000_create_journal_table.php create mode 100644 lib/mfBaseModelV2/README.md create mode 100644 lib/mfBaseModelV2/mfBaseModelV2.php create mode 100644 public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css create mode 100644 public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js 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/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 @@