From f7218ab144911ac25239ce4eec82fa358062fba9 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 21 Jul 2025 22:27:40 +0200 Subject: [PATCH] added v2 of cpeprov --- Layout/default/header.php | 2 +- .../CpeprovisioningController.php | 283 +++++++++++++++++- application/Order/OrderModel.php | 7 + .../OrderProduct/OrderProductModel.php | 4 +- ...20250721222300_cpeprov_add_new_indexes.php | 52 ++++ docker/php/Dockerfile | 11 + docker/php/apache.conf | 20 ++ .../pages/Cpeprovisioning/Cpeprovisioning.css | 159 ++++++++++ .../pages/Cpeprovisioning/Cpeprovisioning.js | 217 ++++++++++++++ .../plugins/bookstack/bookstackIntegration.js | 79 ++--- .../bookstack/bookstackIntegration.min.js | 1 + .../vue/tt-components/css/tt-tooltip.css | 5 +- .../plugins/vue/tt-components/tt-checkbox.js | 3 - 13 files changed, 786 insertions(+), 57 deletions(-) create mode 100644 db/migrations/20250721222300_cpeprov_add_new_indexes.php create mode 100644 public/js/pages/Cpeprovisioning/Cpeprovisioning.css create mode 100644 public/js/pages/Cpeprovisioning/Cpeprovisioning.js create mode 100644 public/plugins/bookstack/bookstackIntegration.min.js diff --git a/Layout/default/header.php b/Layout/default/header.php index cf5329afd..34df75eb0 100644 --- a/Layout/default/header.php +++ b/Layout/default/header.php @@ -56,7 +56,7 @@ - + diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php index 0701e0316..682bbb35c 100644 --- a/application/Cpeprovisioning/CpeprovisioningController.php +++ b/application/Cpeprovisioning/CpeprovisioningController.php @@ -5,12 +5,11 @@ class CpeprovisioningController extends mfBaseController protected function init() { $this->needlogin = true; - $me = new User(); - $me->loadMe(); - $this->me = $me; - $this->layout()->set("me", $me); + $this->me = new User(); + $this->me->loadMe(); + $this->layout()->set("me", $this->me); - if (!$me->is(["Admin"])) { + if (!$this->me->is(["Admin"])) { $this->redirect("Dashboard"); } } @@ -283,11 +282,275 @@ class CpeprovisioningController extends mfBaseController $query["filter"] = $this->request->filter; } - $qs = http_build_query($query); - $this->layout()->setFlash("Eintrag erfolgreich gespeichert.", "success"); - $this->redirect("Cpeprovisioning", "Index", $qs); - + $this->redirect("Cpeprovisioning", "Index", http_build_query($query)); } -} + protected function apiSaveAction() { + try { + $p = json_decode(file_get_contents('php://input'), true); + + $id = $p['id'] ?? null; + $mode = $id ? "edit" : "add"; + + if ($mode === 'edit') { + $cpe = new Cpeprovisioning($id); + if (!$cpe->id) throw new Exception("Eintrag nicht gefunden"); + } + + if (empty($p['termination_id']) && empty($p['order_id'])) throw new Exception("Anschluss oder Bestellung nicht gefunden"); + if (!OrderProductModel::getFirst(["order_id" => $p['order_id'], "termination_id" => $p['termination_id']])) throw new Exception("Anschluss gehört nicht zur Bestellung"); + + if (!empty($p['ont_sn'])) { + $termination = new Termination($p['termination_id']); + $orig_sn = $termination->getWorkflowvalue("ont_sn", "string"); + + if ($orig_sn === null) { + if ($sn_item = WorkflowitemModel::getFirst(["name" => "ont_sn", "object_type" => "termination"])) { + $sn_item->setObjectId($p['termination_id']); + $termination->workflowitems["ont_sn"] = $sn_item; + } else { + $this->log->error("ont_sn workflow item not found"); + } + } + + if ($p['ont_sn'] !== $orig_sn && isset($termination->workflowitems["ont_sn"])) { + $termination->workflowitems["ont_sn"]->value->setValue($p['ont_sn']); + $termination->workflowitems["ont_sn"]->value->save(); + } + } + + $data = [ + "termination_id" => $p['termination_id'] ?: null, + "order_id" => $p['order_id'], + "orderproduct_id" => $p['orderproduct_id'], + "routerconfig_finished" => (int)($p['routerconfig_finished'] ?? 0), + "shipping" => (int)($p['shipping'] ?? 0), + "routertype" => $p['routertype'] ?? null, + "wifi_ssid" => $p['wifi_ssid'] ?? null, + "wifi_pass" => $p['wifi_pass'] ?? null, + "mac" => $p['mac'] ?? null, + "note" => $p['note'] ?? null, + "edit_by" => $this->me->id, + ]; + + foreach (['ship_weight', 'ship_length', 'ship_width', 'ship_height'] as $key) $data[$key] = empty($p[$key]) ? null : $p[$key]; + foreach (['public', 'nat', 'ipv6'] as $type) $data["vlan_{$type}"] = !empty($p['vlans'][$type]['checked']) ? ($p['vlans'][$type]['tag'] ?? null) : null; + + if ($mode === 'add') { + $data["create_by"] = $this->me->id; + $cpe = CpeprovisioningModel::create($data); + } else $cpe->update($data); + + if (!$cpe->save()) throw new Exception("Fehler beim Speichern"); + + if ($cpe->routerconfig_finished) { + $order_product = new OrderProduct($p['orderproduct_id']); + + $shipping_text = $cpe->shipping + ? "zum Versand vorbereitet" + : "vorbereitet für Techniker zur Vorortinstallation"; + + $text = "CPE zu Produkt \"{$order_product->product->name}\" {$shipping_text}.\n\n" + . "Router: {$cpe->routertype}\n" + . "Zugangstyp: {$order_product->product->attributes['bras_type']->value}\n"; + + if ($cpe->vlan_public) $text .= "Vlan Public: {$cpe->vlan_public}\n"; + if ($cpe->vlan_nat) $text .= "Vlan NAT: {$cpe->vlan_nat}\n"; + if ($cpe->vlan_ipv6) $text .= "Vlan IPv6: {$cpe->vlan_ipv6}\n"; + + $journal = new OrderJournal(); + $journal->order_id = $p['order_id']; + $journal->text = $text; + $journal->create_by = $this->me->id; + $journal->edit_by = $this->me->id; + + if (!($journal_id = $journal->save())) { + $this->layout()->setFlash("Konnte nicht ins Bestelljournal schreiben!", "warning"); + } else { + $cpe->order_journal_id = $journal_id; + $cpe->save(); + } + } + + self::returnJson(['success' => true, 'message' => 'Eintrag erfolgreich gespeichert.']); + + } catch (Exception $e) { + http_response_code(400); + self::returnJson(['success' => false, 'message' => $e->getMessage()]); + } + } + protected function apiGetAction() + { + $p = json_decode(file_get_contents('php://input'), true) ?? []; + + // --- Pagination and Sorting setup --- + $page = (int)($p['pagination']['page'] ?? 1); + $perPage = (int)($p['pagination']['per_page'] ?? 25); + $orderBy = $p['order']['key'] ?? null; + $orderDir = $p['order']['order'] ?? 'asc'; + + // Calculate start and end indexes for manual pagination, just like in the old indexAction + $start = ($page - 1) * $perPage; + $end = $start + $perPage; + + // --- Data Fetching (same as before) --- + $searchFilter = $this->getPreparedFilter($p['filters'] ?? []); + // if routerconfig_finished === 0 then we can add key add-where to searchFilter and only show orders withing the last 180 days either by create or edit (caution this is unix timestamp) + if (isset($searchFilter['routerconfig_finished']) && !$searchFilter['routerconfig_finished']) { + $searchFilter['add-where'] = "`Order`.create > " . (time() - 365 * 86400) . " OR `Order`.edit > " . (time() - 365 * 86400); + } + + $orders = OrderModel::search($searchFilter); // cpeprovisioning_enabled + // Use the same uncached precache from indexAction to get all potential products +// $prefetched = OrderProductModel::precache("`Terminationstatus`.`code` < " . TT_TERMSTATUS_CONNECTED . " AND `ProducttechAttribute`.`name` = 'bras_type'"); + $sqlWhere = "`ProducttechAttribute`.`name` = 'bras_type'"; + // if filter routerconfig_finished === 0 then we can filter the precache by routerconfig_finished = 0 or is null + if (isset($searchFilter['routerconfig_finished']) && !$searchFilter['routerconfig_finished']) { +// $sqlWhere .= " AND (`Cpeprovisioning`.`routerconfig_finished` = 0 OR `Cpeprovisioning`.`routerconfig_finished` IS NULL)"; + } + + $prefetched = OrderProductModel::precache($sqlWhere); + + $paginatedRows = []; + $totalRows = 0; + $orderInfoCache = []; // Cache for order-level info to avoid repeated calculations + + foreach ($orders as $order) { + // --- Apply the same initial filters as the old and new methods --- + if (($searchFilter["hide_delayed_finish"] ?? false) && $order->finish_after && $order->finish_after > strtotime('+31 days')) continue; + if (isset($prefetched['terminations'][$order->id][0]) && !$order->cpeprovisioning_enabled && $prefetched['terminations'][$order->id][0]['statuscode'] < TT_TERMSTATUS_CONNECTED) continue; + if (empty($prefetched[$order->id])) continue; + + // Loop through the prefetched raw data, similar to indexAction + foreach ($prefetched[$order->id] as $opData) { + // --- Apply the same product-level filters --- + if (!is_array($opData) || ($opData['routerconfig_finished'] xor ($searchFilter['routerconfig_finished'] ?? false)) || empty($opData['attributes']['bras_type'])) continue; + + // This item is a valid candidate for the list. + $totalRows++; + + // *** THE CORE PERFORMANCE IMPROVEMENT *** + // If the current item is not on the page we want, skip the expensive processing below. + if ($totalRows <= $start || $totalRows > $end) { + continue; + } + + // --- Now, do the expensive processing ONLY for the items on the current page --- + + // Calculate and cache order-level info only when it's first needed for a visible item + if (!isset($orderInfoCache[$order->id])) { + $orderInfo = ['vot' => false, 'hw' => [], 'voip' => false]; + foreach ($order->products as $prod) { + $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['voip_chan']->value ?? false) $orderInfo['voip'] = true; + if ($attrs['vot']->value ?? false) $orderInfo['vot'] = true; + } + $orderInfoCache[$order->id] = $orderInfo; + } + $orderInfo = $orderInfoCache[$order->id]; + + // Hydrate the full model object, but only for this one item + $product = OrderProductModel::getOne($opData['id']); + $term = $product->termination; + $attrs = $product->product->attributes; + $cpe = $product->cpeprovisioning; + + $vlanPublicDefault = $term ? $term->getPop()->vlan_public : ($attrs['vlan_default_public']->value ?? null); + $vlanNatDefault = $term ? $term->getPop()->vlan_nat : ($attrs['vlan_default_nat']->value ?? null); + $vlanIpv6Default = $term ? $term->getPop()->vlan_ipv6 : ($attrs['vlan_default_ipv6']->value ?? null); + + /** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */ + $paginatedRows[] = [ + 'id' => $product->id, 'order_id' => $product->order_id, 'termination_id' => $product->termination_id, 'orderproduct_id' => $product->id, + 'network' => $term->building->network->name ?? "{$order->owner->zip} {$order->owner->city}", + 'spin' => $order->owner->spin, 'customer' => $order->owner->getCompanyOrName(), + 'product_name' => $product->product->name, 'product_code' => $term->code ?? '', + 'access_type' => $attrs['bras_type']->value, + 'access_type_down' => $attrs["bw_down"]->value, + 'access_type_up' => $attrs["bw_up"]->value, + 'ont_deployed' => $term ? $term->getWorkflowValue("ont_deployed", "int") : 0, + 'ont_sn' => $term ? $term->getWorkflowValue("ont_sn", "string") : null, + 'vot' => $orderInfo['vot'] || $order->install_date, + 'hw' => !empty($orderInfo['hw']) ? implode("
", $orderInfo['hw']) : null, + 'voip' => $orderInfo['voip'], + 'note' => $order->note, + 'show_snopp_button' => ($attrs['hostnetwork_order']->value ?? 0) == 1 && !str_contains($product->product->name, 'XDSL'), + 'snopp_url' => 'https://snopp.breitband-steiermark.at/Termination/index?filter[status][]=connected&filter[address]=' . urlencode($order->owner->street), + 'vlans' => [ + 'public' => ['tag' => $cpe->vlan_public ?? $vlanPublicDefault, 'checked' => $product->cpeprovisioning->vlan_public], + 'nat' => ['tag' => $cpe->vlan_nat ?? $vlanNatDefault, 'checked' => $product->cpeprovisioning->vlan_nat], + 'ipv6' => ['tag' => $cpe->vlan_ipv6 ?? $vlanIpv6Default, 'checked' => $product->cpeprovisioning->vlan_ipv6], + ], + 'cpe_id' => $cpe->id ?? null, + 'cpe_data' => $this->fixCpeData($cpe->data ?? null), + ]; + } + } + + // Sort the final (small) array of results for the current page + if ($orderBy) { + usort($paginatedRows, fn($a, $b) => ($orderDir === 'asc' ? 1 : -1) * strnatcasecmp($a[$orderBy] ?? '', $b[$orderBy] ?? '')); + } + + // No need for array_slice, we already built the paginated list manually + self::returnJson([ + 'rows' => $paginatedRows, + 'pagination' => [ + 'page' => $page, 'per_page' => $perPage, 'total_rows' => $totalRows, + 'filtered_available' => $totalRows, 'total_pages' => ceil($totalRows / $perPage) + ] + ]); + } + + protected function newIndexAction() + { + $this->layout()->set('additionalJS', ['js/pages/Cpeprovisioning/Cpeprovisioning.js']); + $this->layout()->set('additionalHead', ['']); + + Helper::renderVue( + $this, + "Cpeprovisioning", // The root Vue component name + "CPE Provisioning", // The page title + [ + // Pass API URLs and initial data to the frontend + "CPE_PROV_API_GET_URL" => $this->getUrl("Cpeprovisioning", "apiGet"), + "CPE_PROV_API_SAVE_URL" => $this->getUrl("Cpeprovisioning", "apiSave"), + "ORDER_URL" => $this->getUrl("Order"), + "NETWORKS" => NetworkModel::getAll(), + "ROUTER_OPTIONS" => [ + // General Options + ['value' => 'eigener Router', 'text' => 'Eigener Router'], + ['value' => 'anderes CPE', 'text' => 'Anderes CPE'], + // PPPoE/DHCP Routers + ['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'], + ['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'], + ['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'], + ['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'], + ['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'], + ['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'], + ['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'], + // Static Routers + ['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'], + ['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'], + ['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'], + // CMTS Routers + ['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'], + ] + ] + ); + } + + private function fixCpeData($data) { + if (!$data) return []; + $data->shipping = (bool)$data->shipping; + $data->routerconfig_finished = (bool)$data->routerconfig_finished; + return $data; + } + + +} \ No newline at end of file diff --git a/application/Order/OrderModel.php b/application/Order/OrderModel.php index f491dae57..caee15c8a 100644 --- a/application/Order/OrderModel.php +++ b/application/Order/OrderModel.php @@ -529,6 +529,13 @@ class OrderModel { } } + + if(array_key_exists("add-where", $filter)) { + $add_where = $filter['add-where']; + if($add_where) { + $where .= " AND ($add_where)"; + } + } //var_dump($filter, $where);exit; return $where; diff --git a/application/OrderProduct/OrderProductModel.php b/application/OrderProduct/OrderProductModel.php index 98c3759f5..e9ab464d6 100644 --- a/application/OrderProduct/OrderProductModel.php +++ b/application/OrderProduct/OrderProductModel.php @@ -141,7 +141,7 @@ class OrderProductModel return $items; } - public static function precache() + public static function precache($where = false): array { $items = []; $db = FronkDB::singleton(); @@ -162,7 +162,7 @@ class OrderProductModel "; //mfLoghandler::singleton()->debug($sql); - $res = $db->query($sql); + $res = $db->query($sql . ($where ? " WHERE $where" : "")); if ($db->num_rows($res)) { $oldProduct = ""; $oldOrder = ""; diff --git a/db/migrations/20250721222300_cpeprov_add_new_indexes.php b/db/migrations/20250721222300_cpeprov_add_new_indexes.php new file mode 100644 index 000000000..894fd5c05 --- /dev/null +++ b/db/migrations/20250721222300_cpeprov_add_new_indexes.php @@ -0,0 +1,52 @@ +getEnvironment() == 'thetool') { + $orderProduct = $this->table('OrderProduct'); + $orderProduct->addIndex('product_id', ['name' => 'idx_product_id']) + ->addIndex('termination_id', ['name' => 'idx_termination_id']) + ->save(); + + $product = $this->table('Product'); + $product->addIndex('producttech_id', ['name' => 'idx_producttech_id']) + ->save(); + + $productAttribute = $this->table('ProductAttribute'); + $productAttribute->addIndex('producttechattribute_id', ['name' => 'idx_producttechattribute_id']) + ->save(); + + $termination = $this->table('Termination'); + $termination->addIndex('status_id', ['name' => 'idx_status_id']) + ->save(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == 'thetool') { + $orderProduct = $this->table('OrderProduct'); + $orderProduct->removeIndexByName('idx_product_id') + ->removeIndexByName('idx_termination_id') + ->save(); + + $product = $this->table('Product'); + $product->removeIndexByName('idx_producttech_id') + ->save(); + + $productAttribute = $this->table('ProductAttribute'); + $productAttribute->removeIndexByName('idx_producttechattribute_id') + ->save(); + + $termination = $this->table('Termination'); + $termination->removeIndexByName('idx_status_id') + ->save(); + } + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 432a43e52..8561a7318 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -37,3 +37,14 @@ RUN echo "* * * * * /root/clean_old_logs.sh" > /etc/cron.d/clean_old_logs && \ # Start Apache in the foreground CMD ["apachectl", "-D", "FOREGROUND"] + + +# Install XDEBUG +# apt install -y php8.2-xdebug +# +# cat <<'EOF' > /etc/php/8.2/apache2/conf.d/99-xdebug-custom.ini + #[xdebug] + #xdebug.mode=profile + #xdebug.start_with_request=trigger + #xdebug.output_dir="/tmp/xdebug_profiles" + #EOF \ No newline at end of file diff --git a/docker/php/apache.conf b/docker/php/apache.conf index 29f0d6673..dd7e83203 100644 --- a/docker/php/apache.conf +++ b/docker/php/apache.conf @@ -6,4 +6,24 @@ AllowOverride All + + + # Enable compression + SetOutputFilter DEFLATE + + # Set compression level (1-9, 9 is highest) + DeflateCompressionLevel 6 + + # Add compression for specific MIME types + AddOutputFilterByType DEFLATE text/plain + AddOutputFilterByType DEFLATE text/html + AddOutputFilterByType DEFLATE text/xml + AddOutputFilterByType DEFLATE text/css + AddOutputFilterByType DEFLATE application/xml + AddOutputFilterByType DEFLATE application/xhtml+xml + AddOutputFilterByType DEFLATE application/rss+xml + AddOutputFilterByType DEFLATE application/javascript + AddOutputFilterByType DEFLATE application/json + AddOutputFilterByType DEFLATE image/svg+xml + diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.css b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css new file mode 100644 index 000000000..83fad4933 --- /dev/null +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css @@ -0,0 +1,159 @@ +/* Cpeprovisioning.css */ + +.cpe-provisioning-page .filter-card { + margin-bottom: 1rem; +} + +.cpe-provisioning-page .filter-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + align-items: end; +} + +.cpe-provisioning-page .filter-actions { + display: flex; + gap: 0.5rem; + padding-top: 1.5rem; /* Align with form labels */ +} + +.cpe-provisioning-page .cpe-details-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-top: 1px solid #dee2e6; +} + +.cpe-provisioning-page .section-title { + grid-column: 1 / -1; + font-size: 1.1rem; + font-weight: 600; + color: #005384; + margin-top: 0.5rem; + margin-bottom: 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid #f7c423; +} + +.cpe-provisioning-page .info-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.cpe-provisioning-page .info-pill { + background-color: #e9ecef; + color: #495057; + padding: 0.25rem 0.6rem; + border-radius: 1rem; + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; +} + +.cpe-provisioning-page .info-pill i { + color: #005384; +} + +/* Ensure form groups don't have excessive bottom margin in the grid */ +.cpe-provisioning-page .cpe-details-grid .form-group { + margin-bottom: 0; +} + +/* Save button alignment */ +.cpe-provisioning-page .save-button-container { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} + +/* For tt-table expanded row content */ +.tt-table tbody tr[style*="display: table-row;"] > td { + background-color: #f8f9fa !important; + padding: 0; +} + +/* Change tracking */ +.cpe-provisioning-page .is-dirty { + background-color: #fff3cd; /* A light yellow to indicate changes */ + border-color: #ffeeba; +} + +.cpe-provisioning-page .form-group-condensed .col-form-label { + padding-bottom: 0; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .cpe-provisioning-page .filter-grid, + .cpe-provisioning-page .cpe-details-grid { + grid-template-columns: 1fr; /* Stack on smaller screens */ + } + + .cpe-provisioning-page .filter-actions { + padding-top: 0; + flex-direction: column; + } + + .cpe-provisioning-page .filter-actions .btn { + width: 100%; + } +} + + +.vlans-container { + display: flex; + flex-wrap: wrap; + /*center items in the middle of the full width*/ + justify-content: center; + /*flex-direction: column;*/ + gap: 0.5rem; +} + +/* TODO: MOVE TT-CHIP TO OWN FILE */ +.tt-chip { + display: inline-flex; + align-items: center; + padding: 5px 12px; + border-radius: 16px; /* Pill shape */ + background-color: #f1f3f5; /* Light grey for unchecked state */ + border: 1px solid #dee2e6; + font-size: 0.875em; /* 14px if base is 16px */ + margin: 3px; + transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.tt-chip.is-checked { + background-color: #e7f5ff; /* A pleasant light blue for the checked state */ + border-color: #a5d8ff; + color: #1c7ed6; + font-weight: 500; +} + +/* Add a subtle shadow on hover for interactivity */ +.tt-chip:hover { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Style elements passed into the slot for consistent spacing and alignment */ +.tt-chip > * + * { + margin-left: 8px; +} + +.tt-chip input[type="checkbox"] { + margin: 0; + width: 15px; + height: 15px; + cursor: pointer; +} diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.js b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js new file mode 100644 index 000000000..e3b1aff1e --- /dev/null +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js @@ -0,0 +1,217 @@ +// Cpeprovisioning.js +Vue.component('tt-chip', { + props: { + checked: { type: Boolean, default: false } + }, + template: `
` +}); + +Vue.component('Cpeprovisioning', { + template: ` +
+ +
+
+ + + + +
+ + +
+
+
+
+ + + + + + + + + + + + + +
+ `, + data() { + return { + window, + filters: { + network_id: '', + routerconfig_finished: '0', + hide_delayed_finish: '1', + owner: '' + }, + statusOptions: [ + { value: '0', text: 'Offen' }, + { value: '1', text: 'Abgeschlossen' } + ], + delayOptions: [ + { value: '1', text: 'Nicht anzeigen' }, + { value: '0', text: 'Anzeigen' } + ], + tableConfig: { + key: 'cpeProvisioning', + tableHeader: 'CPE Provisioning', + expandCondition: () => true, + customRowClass: row => (row.isDirty ? 'is-dirty' : ''), + headers: [ + { key: 'customer', text: 'Kunde', sortable: false, filter: false, priority: 100 }, + { key: 'product', text: 'Produkt', sortable: false, filter: false, priority: 90 }, + { key: 'vlans', text: 'VLANs', sortable: false, filter: false, priority: 80 }, + ] + } + } + }, + computed: { + networkOptions() { + const networks = window.TT_CONFIG.NETWORKS || []; + return [{ value: '', text: 'Alle Gebiete' }, ...networks.map(net => ({ value: net.id, text: net.name }))]; + }, + routerOptions() { + return window.TT_CONFIG.ROUTER_OPTIONS || []; + } + }, + methods: { + applyFilters(fetch = true) { + const table = this.$refs.cpeTable; + if (table) { + table.filters = JSON.parse(JSON.stringify(this.filters)); + if (fetch) table.fetchData(1); + } + }, + resetFilters() { + this.filters = { + network_id: '', + routerconfig_finished: '0', + hide_delayed_finish: '1', + owner: '' + }; + this.applyFilters(); + }, + markDirty(row, field) { + console.log(`Marking row as dirty for field ${field}`); + this.$set(row, 'isDirty', true); + }, + async saveCpe(row) { + this.$set(row, 'isSaving', true); + + const payload = { + id: row.cpe_id, + order_id: row.order_id, + orderproduct_id: row.orderproduct_id, + termination_id: row.termination_id, + ont_sn: row.ont_sn, + vlans: row.vlans, + ...row.cpe_data, + shipping: row.cpe_data.shipping ? 1 : 0, + routerconfig_finished: row.cpe_data.routerconfig_finished ? 1 : 0, + }; + + try { + const { data } = await axios.post(this.window.TT_CONFIG.CPE_PROV_API_SAVE_URL, payload); + if (data.success) { + this.window.notify('success', data.message); + this.$set(row, 'isDirty', false); + this.$refs.cpeTable.refreshTable(); + } else { + this.window.notify('error', data.message || 'Fehler beim Speichern.'); + } + } catch (error) { + this.window.notify('error', 'Ein unerwarteter Fehler ist aufgetreten.'); + } finally { + this.$set(row, 'isSaving', false); + } + } + }, + mounted() { + this.applyFilters(false); + } +}); \ No newline at end of file diff --git a/public/plugins/bookstack/bookstackIntegration.js b/public/plugins/bookstack/bookstackIntegration.js index 23abfdde0..59523a5bb 100644 --- a/public/plugins/bookstack/bookstackIntegration.js +++ b/public/plugins/bookstack/bookstackIntegration.js @@ -1,50 +1,51 @@ document.addEventListener('DOMContentLoaded', async () => { - const articleTag = (() => { - const path = window.location.pathname; - const segments = path.split('/').filter(Boolean); - return segments.length > 0 ? segments[0] : 'DefaultTag'; - })(); + const linkEl = document.getElementById('bookstackLink'); + if (!linkEl) return; + const articleTag = window.location.pathname.split('/').filter(Boolean)[0] || 'DefaultTag'; + const cacheKey = `bookstack_article_${articleTag}`; - const apiUrl = `https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(articleTag)}%5D%7Btype%3Apage%7D`; - const linkElement = document.getElementById('bookstackLink'); + const setupLinkAction = url => { + linkEl.style.display = 'block'; + linkEl.querySelector('a').onclick = e => { + e.preventDefault(); + const modal = document.createElement('div'); + modal.className = 'bookstack-integration-modal'; + modal.innerHTML = `
`; + modal.onclick = ev => { + if (ev.target === modal || ev.target.classList.contains('bookstack-integration-close-btn')) { + modal.remove(); + } + }; + document.body.appendChild(modal); + }; + }; + + document.addEventListener('keydown', e => { + if (e.ctrlKey && e.key === 'F8') { + e.preventDefault(); + localStorage.removeItem(cacheKey); + window.notify('success', `📗 BookStack cache für '${articleTag}' wurde gelöscht.`); + } + }); try { - const response = await fetch(apiUrl, { - headers: { - 'Authorization': 'Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i' - } + const cachedItem = JSON.parse(localStorage.getItem(cacheKey) || 'null'); + if (cachedItem && (Date.now() - cachedItem.timestamp < (cachedItem.url ? 604800000 : 259200000))) { + if (cachedItem.url) setupLinkAction(cachedItem.url); + return; + } + + const response = await fetch(`https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(articleTag)}%5D%7Btype%3Apage%7D`, { + headers: { 'Authorization': 'Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i' } }); const data = await response.json(); + const articleUrl = data.data?.[0]?.url || null; - if (data.data && data.data.length > 0) { - const article = data.data[0]; - linkElement.style.display = 'block'; - linkElement.querySelector('a').addEventListener('click', (e) => { - e.preventDefault(); - showArticleModal(article.url); - }); - } + localStorage.setItem(cacheKey, JSON.stringify({ url: articleUrl, timestamp: Date.now() })); + articleUrl ? setupLinkAction(articleUrl) : (linkEl.style.display = 'none'); } catch (error) { console.error('BookStack API error:', error); - linkElement.style.display = 'none'; + linkEl.style.display = 'none'; } - - function showArticleModal(url) { - const modal = document.createElement('div'); - modal.className = 'bookstack-integration-modal'; - modal.innerHTML = ` -
- - -
- `; - - modal.querySelector('.bookstack-integration-close-btn').addEventListener('click', () => modal.remove()); - modal.addEventListener('click', (e) => { - if (e.target === modal) modal.remove(); - }); - - document.body.appendChild(modal); - } -}); +}); \ No newline at end of file diff --git a/public/plugins/bookstack/bookstackIntegration.min.js b/public/plugins/bookstack/bookstackIntegration.min.js new file mode 100644 index 000000000..97df3ee40 --- /dev/null +++ b/public/plugins/bookstack/bookstackIntegration.min.js @@ -0,0 +1 @@ +document.addEventListener("DOMContentLoaded",(async()=>{const t=document.getElementById("bookstackLink");if(!t)return;const e=window.location.pathname.split("/").filter(Boolean)[0]||"DefaultTag",o=`bookstack_article_${e}`,a=e=>{t.style.display="block",t.querySelector("a").onclick=t=>{t.preventDefault();const o=document.createElement("div");o.className="bookstack-integration-modal",o.innerHTML=`
`,o.onclick=t=>{(t.target===o||t.target.classList.contains("bookstack-integration-close-btn"))&&o.remove()},document.body.appendChild(o)}};document.addEventListener("keydown",(t=>{t.ctrlKey&&"F8"===t.key&&(t.preventDefault(),localStorage.removeItem(o),window.notify("success",`📗 BookStack cache für '${e}' wurde gelöscht.`))}));try{const n=JSON.parse(localStorage.getItem(o)||"null");if(n&&Date.now()-n.timestamp<(n.url?6048e5:2592e5))return void(n.url&&a(n.url));const c=await fetch(`https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(e)}%5D%7Btype%3Apage%7D`,{headers:{Authorization:"Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i"}}),r=await c.json(),s=r.data?.[0]?.url||null;localStorage.setItem(o,JSON.stringify({url:s,timestamp:Date.now()})),s?a(s):t.style.display="none"}catch(e){console.error("BookStack API error:",e),t.style.display="none"}})); \ No newline at end of file diff --git a/public/plugins/vue/tt-components/css/tt-tooltip.css b/public/plugins/vue/tt-components/css/tt-tooltip.css index f33131e51..f180459ec 100644 --- a/public/plugins/vue/tt-components/css/tt-tooltip.css +++ b/public/plugins/vue/tt-components/css/tt-tooltip.css @@ -18,7 +18,7 @@ text-align: center; /* Center text */ } -/* Make tooltip visible when showTooltip is true */ +/* Make tooltip visible on hover */ .tt-tooltip-wrapper:hover .tt-tooltip-box { opacity: 1; } @@ -89,7 +89,8 @@ border-color: transparent #333 transparent transparent; } +/* The problematic 'width: 100% !important;' has been removed from the selector below. +*/ .tt-tooltip-wrapper > * { display: inline-block; /* Ensure the tooltip wrapper behaves correctly */ - width: 100% !important; } \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-checkbox.js b/public/plugins/vue/tt-components/tt-checkbox.js index 40f501de1..f76a45476 100644 --- a/public/plugins/vue/tt-components/tt-checkbox.js +++ b/public/plugins/vue/tt-components/tt-checkbox.js @@ -18,9 +18,6 @@ Vue.component('tt-checkbox', { this.checkedValue = val; } }, - mounted() { - this.$emit('input', this.checkedValue); - }, template: `