added v2 of cpeprov
This commit is contained in:
@@ -56,7 +56,7 @@
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap-autocomplete.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>datatables/datatables.min.js?<?=$git_merge_ts?>"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>assets/libs/switchery/switchery.min.js"></script>
|
||||
|
||||
|
||||
|
||||
@@ -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("<br />", $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', ['<link rel="stylesheet" href="/js/pages/Cpeprovisioning/Cpeprovisioning.css">']);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -530,6 +530,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;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
52
db/migrations/20250721222300_cpeprov_add_new_indexes.php
Normal file
52
db/migrations/20250721222300_cpeprov_add_new_indexes.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CpeprovAddNewIndexes extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -6,4 +6,24 @@
|
||||
<Directory /var/www/html>
|
||||
AllowOverride All
|
||||
</Directory>
|
||||
|
||||
<IfModule mod_deflate.c>
|
||||
# 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
|
||||
</IfModule>
|
||||
</VirtualHost>
|
||||
|
||||
159
public/js/pages/Cpeprovisioning/Cpeprovisioning.css
Normal file
159
public/js/pages/Cpeprovisioning/Cpeprovisioning.css
Normal file
@@ -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;
|
||||
}
|
||||
217
public/js/pages/Cpeprovisioning/Cpeprovisioning.js
Normal file
217
public/js/pages/Cpeprovisioning/Cpeprovisioning.js
Normal file
@@ -0,0 +1,217 @@
|
||||
// Cpeprovisioning.js
|
||||
Vue.component('tt-chip', {
|
||||
props: {
|
||||
checked: { type: Boolean, default: false }
|
||||
},
|
||||
template: `<div class="tt-chip" :class="{ 'is-checked': checked }"><slot></slot></div>`
|
||||
});
|
||||
|
||||
Vue.component('Cpeprovisioning', {
|
||||
template: `
|
||||
<div class="cpe-provisioning-page">
|
||||
<tt-card>
|
||||
<div class="filter-card">
|
||||
<div class="filter-grid">
|
||||
<tt-select label="Netzgebiet" :options="networkOptions" v-model="filters.network_id" sm/>
|
||||
<tt-select label="Provisioningstatus" :options="statusOptions" v-model="filters.routerconfig_finished" sm/>
|
||||
<tt-select label="Verzögerte Herstellung" :options="delayOptions" v-model="filters.hide_delayed_finish" sm/>
|
||||
<tt-input label="Kunde" v-model="filters.owner" sm placeholder="Name, SPIN, ..."/>
|
||||
<div class="filter-actions">
|
||||
<tt-button text="Anwenden" @click="applyFilters" additional-class="btn-primary" sm/>
|
||||
<tt-button text="Zurücksetzen" @click="resetFilters" additional-class="btn-secondary" sm/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</tt-card>
|
||||
|
||||
<tt-card :no-body-padding-top="true">
|
||||
<tt-table
|
||||
:fetch-url="window.TT_CONFIG.CPE_PROV_API_GET_URL"
|
||||
:config="tableConfig"
|
||||
ref="cpeTable"
|
||||
key="cpeProvisioningTable"
|
||||
ssr>
|
||||
|
||||
<template v-slot:customer="{ row }">
|
||||
<div><strong>{{ row.customer }}</strong></div>
|
||||
<div><small class="text-muted">SPIN: {{ row.spin }}</small></div>
|
||||
<div><small>{{ row.network }}</small></div>
|
||||
</template>
|
||||
|
||||
<template v-slot:product="{ row }">
|
||||
<div><strong>{{ row.product_name }}</strong></div>
|
||||
<div><small class="text-muted">{{ row.product_code }}</small></div>
|
||||
<div>
|
||||
<small>{{ row.access_type }}</small>
|
||||
<small><i class="fas fa-arrow-down"></i> {{ row.access_type_down }} Mbit/s</small>
|
||||
<small><i class="fas fa-arrow-up"></i> {{ row.access_type_up }} Mbit/s</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 d-flex align-items-center">
|
||||
<a target="_blank" :href="window.TT_CONFIG.ORDER_URL + '/Index/?id=' + row.order_id + '&addJournal=1'" class="mr-2">
|
||||
<i class="fas fa-scroll"></i>
|
||||
</a>
|
||||
<template v-if="row.vot || row.hw || row.voip || row.note">
|
||||
<tt-tooltip v-if="row.vot" text="Vorortinstallation" position="top">
|
||||
<i class="fas fa-tools text-purple mr-2"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.hw" :text="row.hw" position="top" class="mr-2">
|
||||
<i class="fas fa-shopping-bag text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.voip" text="Voice Produkt vorhanden" position="top" class="mr-2">
|
||||
<i class="fas fa-phone text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.note" :text="row.note" position="top" allow-wrapping class="mr-2">
|
||||
<i class="fas fa-clipboard-list text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<a target="_blank" :href="row.snopp_url" class="mr-2">
|
||||
<tt-tooltip v-if="row.show_snopp_button" text="SNOPP" position="top" allow-wrapping>
|
||||
<img style="height: 18px; vertical-align: middle;" class="logo-top-search" src="/img/snop-logo.png" alt="Snop Logo">
|
||||
</tt-tooltip>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:vlans="{ row }">
|
||||
<div class="vlans-container">
|
||||
<template v-for="(vlan, key) in row.vlans">
|
||||
<tt-chip v-if="vlan.tag" class="vlan-chip" :key="key">
|
||||
<input type="checkbox" :checked="vlan.checked" @change="markDirty(row, 1); vlan.checked = !vlan.checked"/>
|
||||
<span>{{ key.charAt(0).toUpperCase() + key.slice(1) }}: {{ vlan.tag }}</span>
|
||||
</tt-chip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div class="cpe-details-grid">
|
||||
<h5 class="section-title">Router Konfiguration</h5>
|
||||
<tt-select label="Router" :options="routerOptions" v-model="row.cpe_data.routertype" @input="markDirty(row, 2)" sm/>
|
||||
<tt-input label="WLAN SSID" v-model="row.cpe_data.wifi_ssid" @input="markDirty(row, 3)" sm/>
|
||||
<tt-input label="WPA Key" v-model="row.cpe_data.wifi_pass" @input="markDirty(row, 4)" sm/>
|
||||
<tt-input label="Router MAC" v-model="row.cpe_data.mac" @input="markDirty(row, 5)" sm/>
|
||||
<tt-input label="ONT SN" v-model="row.ont_sn" @input="markDirty(row, 6)" sm/>
|
||||
|
||||
<h5 class="section-title">Versand</h5>
|
||||
<tt-checkbox label="Versandauftrag" v-model="row.cpe_data.shipping" @input="markDirty(row, 7)" sm/>
|
||||
<tt-input label="Gewicht (kg)" v-model="row.cpe_data.ship_weight" @input="markDirty(row, 8)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Länge (cm)" v-model="row.cpe_data.ship_length" @input="markDirty(row, 9)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Breite (cm)" v-model="row.cpe_data.ship_width" @input="markDirty(row, 10)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Höhe (cm)" v-model="row.cpe_data.ship_height" @input="markDirty(row, 11)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
|
||||
<h5 class="section-title">Abschluss</h5>
|
||||
<div style="grid-column: 1 / -1;">
|
||||
<tt-textarea label="Kommentar" v-model="row.cpe_data.note" @input="markDirty(row, 12)" sm/>
|
||||
</div>
|
||||
<tt-checkbox label="Konfig abgeschlossen" v-model="row.cpe_data.routerconfig_finished" @input="markDirty(row, 13)" sm/>
|
||||
|
||||
<div class="save-button-container">
|
||||
<tt-button
|
||||
text="Speichern"
|
||||
@click="saveCpe(row)"
|
||||
:loading="row.isSaving"
|
||||
:disabled="!row.isDirty"
|
||||
:additional-class="row.isDirty ? 'btn-success' : 'btn-secondary'"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tt-table>
|
||||
</tt-card>
|
||||
</div>
|
||||
`,
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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 = `<div class="bookstack-integration-modal-content"><button class="bookstack-integration-close-btn">×</button><iframe src="${url}?iframe=true" class="bookstack-integration-iframe"></iframe></div>`;
|
||||
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';
|
||||
}
|
||||
|
||||
function showArticleModal(url) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'bookstack-integration-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="bookstack-integration-modal-content">
|
||||
<button class="bookstack-integration-close-btn">×</button>
|
||||
<iframe src="${url}?iframe=true" class="bookstack-integration-iframe"></iframe>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.querySelector('.bookstack-integration-close-btn').addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
linkEl.style.display = 'none';
|
||||
}
|
||||
});
|
||||
1
public/plugins/bookstack/bookstackIntegration.min.js
vendored
Normal file
1
public/plugins/bookstack/bookstackIntegration.min.js
vendored
Normal file
@@ -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=`<div class="bookstack-integration-modal-content"><button class="bookstack-integration-close-btn">×</button><iframe src="${e}?iframe=true" class="bookstack-integration-iframe"></iframe></div>`,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"}}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,9 +18,6 @@ Vue.component('tt-checkbox', {
|
||||
this.checkedValue = val;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('input', this.checkedValue);
|
||||
},
|
||||
template: `
|
||||
<div class="form-group" :class="{'row': row}">
|
||||
<slot name="prepend"></slot>
|
||||
|
||||
Reference in New Issue
Block a user