Reworked Device Table and added table features
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
$pagination_baseurl = $this->getUrl($Mod, "Index");
|
||||
$pagination_baseurl_params = ["filter" => $filter];
|
||||
$pagination_entity_name = "Device";
|
||||
?>
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
<link href="<?= self::getResourcePath() ?>assets/css/datatables-std.css?<?= date('U') ?>" rel="stylesheet"
|
||||
type="text/css"/>
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<!-- start page title -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="page-title-box">
|
||||
<div class="page-title-right">
|
||||
<ol class="breadcrumb m-0">
|
||||
<li class="breadcrumb-item"><a href="<?= self::getUrl("Dashboard") ?>"><?= MFAPPNAME_SLUG ?></a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active">Devices</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h4 class="page-title">Devices</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end page title -->
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body mb-3">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="float-left">
|
||||
<h4 class="header-title">Liste aller Devices</h4>
|
||||
</div>
|
||||
<div class="float-right ">
|
||||
<a class="btn btn-primary mb-2" href="<?= self::getUrl("Device", "add") ?>"><i
|
||||
class="fas fa-plus"></i><span
|
||||
class="d-none d-lg-inline"> Neues Device anlegen</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table id="datatable" class="table table-striped table-hover table-sm font-13">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="all">Device Name</th>
|
||||
<th class="text-center ">Geräte Typ</th>
|
||||
<th class="text-center ">Hersteller</th>
|
||||
<th class="text-center ">Pop/Adresse</th>
|
||||
<th class="text-center all">IP-Adresse</th>
|
||||
<th class="text-center">Mac-Adresse</th>
|
||||
<th class="text-center">Seriennummer</th>
|
||||
<th class="text-center">Preis</th>
|
||||
<th class="text-center text-nowrap ml-2 mr-2">max. Leistung</th>
|
||||
<th class="text-center">Backup</th>
|
||||
<th class="edit-width"></th>
|
||||
</tr>
|
||||
<tr id="filterrow">
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="pr-1"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
|
||||
foreach ($devices as $device):
|
||||
if ($device->price != "0.00") {
|
||||
$price = $device->price;
|
||||
} else {
|
||||
$price = $device->devicetype->price;
|
||||
}
|
||||
if ($device->power != "0.0") {
|
||||
$power = $device->power;
|
||||
} else {
|
||||
$power = $device->devicetype->power;
|
||||
}
|
||||
|
||||
if ($device->last_config_backup) {
|
||||
if (time() - $device->last_config_backup <= 172800) {
|
||||
$backup = '<i class="fa-regular fa-circle-check"><span style="display: none">OK</span></i>';
|
||||
} else {
|
||||
$backup = '<i class="fa-regular fa-circle-xmark" title="Letztes Configbackup älter als 48 Stunden"><span style="display: none">AGED</span></i>';
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
$backup = '<i class="fa-regular fa-ban" title="Kein Configbackup"><span style="display: none">N/A</span></i>';
|
||||
}
|
||||
if ($device->autobackup==1) {
|
||||
$backup = '<i class="fa-regular fa-circle-a mr-1"><span style="display: none">Auto</span></i>'.$backup;
|
||||
}
|
||||
if ($device->pop->id) {
|
||||
$destination = '<a href="' . self::getUrl("Pop", "Detail", ["id" => $device->pop->id]) . '">' . $device->pop->name . '</a>';
|
||||
} else if ($device->addr_street) {
|
||||
$address = $device->addr_street . " " . $device->addr_number . ", " . $device->addr_zip . " " . $device->addr_city;
|
||||
$destination = '<a title="Google-Maps: ' . $address . '" class="mapsLink" href="http://maps.google.com/?q=' . $address . '" target="_blank">' . $address . '</a>';
|
||||
} else if ($device->gps_lat) {
|
||||
$address = $device->gps_lat . " , " . $device->gps_long;
|
||||
$destination = '<a title="Google-Maps: ' . $address . '" class="mapsLink" href="http://maps.google.com/?q=' . $address . '" target="_blank">' . $address . '</a>';
|
||||
} else {
|
||||
$destination = '';
|
||||
}
|
||||
?>
|
||||
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<a class=" text-nowrap"
|
||||
href="<?= self::getUrl("Device", "Detail", ["id" => $device->id]) ?>"><?= $device->name ?></a>
|
||||
</td>
|
||||
<td class="text-center"><?= $device->devicetype->name ?></td>
|
||||
<td class="text-center"><?= $device->devicetype->devicemanufactor->name ?></td>
|
||||
<td class="text-center text-nowrap"><?= $destination ?></td>
|
||||
|
||||
|
||||
<td class="text-center"><?= $device->ip ?></td>
|
||||
<td class="text-center"><?= $device->mac ?></td>
|
||||
<td class="text-center"><?= $device->serial ?></td>
|
||||
<td class="text-right text-nowrap"><?= $price ?> €</td>
|
||||
<td class="text-right text-nowrap"><?= $power ?> Watt</td>
|
||||
<td class="text-center"><?= $backup ?></td>
|
||||
|
||||
|
||||
<td style="text-align: left; letter-spacing: 4px; font-size: 1.1em; width: 80px">
|
||||
<a href="<?= self::getUrl("Device", "edit", ["id" => $device->id]) ?>"><i
|
||||
class="far fa-edit" title="Bearbeiten"></i></a>
|
||||
<a href="<?= self::getUrl("Device", "delete", ["id" => $device->id]) ?>"
|
||||
onclick="if(!confirm('Device wirklich löschen?')) return false;" class="text-danger"
|
||||
title="Löschen"><i class="fas fa-trash "></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
var hidesearch = [7, 8, 10];
|
||||
var columndefs = {type: 'ip-address', targets: 4};
|
||||
|
||||
var columnfilter = [9];
|
||||
var columnoptions = '<option value=""></option><option value="OK">OK</option><option value="AGED">AGED</option><option value="N/A">N/A</option><option value="Auto">AUTO</option>';
|
||||
$(document).ready(function () {
|
||||
|
||||
});
|
||||
</script>
|
||||
<script type="text/javascript"
|
||||
src="<?= self::getResourcePath() ?>assets/js/datatables-std.js?<?= date('U') ?>"></script>
|
||||
|
||||
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
@@ -17,11 +17,36 @@ class DeviceController extends mfBaseController
|
||||
|
||||
protected function indexAction()
|
||||
{
|
||||
$deviceManufacturers = array_map(function($manufacturer) {
|
||||
return [
|
||||
"text" => $manufacturer->name,
|
||||
"value" => $manufacturer->name,
|
||||
];
|
||||
}, DevicemanufactorModel::getAll());
|
||||
|
||||
$this->layout()->setTemplate("Device/Index");
|
||||
$devices = DeviceModel::getAll();
|
||||
$this->layout()->set("devices", $devices);
|
||||
$deviceTypes = array_map(function($deviceType) {
|
||||
return [
|
||||
"text" => $deviceType->name,
|
||||
"value" => $deviceType->name,
|
||||
];
|
||||
}, DevicetypeModel::getAll());
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Device"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Devices",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Devices", "href" => self::getUrl("Device")]
|
||||
],
|
||||
"DEVICE_MANUFACTURERS" => $deviceManufacturers,
|
||||
"DEVICE_TYPES" => $deviceTypes,
|
||||
"DEVICE_API_URL" => self::getUrl("Device/api"),
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "Device");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +108,7 @@ class DeviceController extends mfBaseController
|
||||
}
|
||||
|
||||
$this->layout()->set("device", $device);
|
||||
return $this->addAction();
|
||||
$this->addAction();
|
||||
}
|
||||
|
||||
protected function saveAction()
|
||||
@@ -253,57 +278,25 @@ class DeviceController extends mfBaseController
|
||||
|
||||
switch ($do) {
|
||||
case "getDevices":
|
||||
$devices = DeviceModel::getAll();
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$locationText = "";
|
||||
$locationUrl = "";
|
||||
|
||||
if (trim($device->pop->name)) {
|
||||
$locationText = $device->pop->name;
|
||||
$locationUrl = self::getUrl("Pop", "Detail", ["id" => $device->pop->id]);
|
||||
} else if (trim($device->addr_street)) {
|
||||
$locationText = $device->addr_street . " " . $device->addr_number . ", " . $device->addr_zip . " " . $device->addr_city;
|
||||
$locationUrl = "http://maps.google.com/?q=" . $locationText;
|
||||
} else if (trim($device->gps_lat)) {
|
||||
$locationText = $device->gps_lat . " , " . $device->gps_long;
|
||||
$locationUrl = "http://maps.google.com/?q=" . $locationText;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
"name" => $device->name,
|
||||
"devicetype" => $device->devicetype->name,
|
||||
"devicemanufactor" => $device->devicetype->devicemanufactor->name,
|
||||
"locationText" => $locationText,
|
||||
"locationUrl" => $locationUrl,
|
||||
"ip" => $device->ip,
|
||||
"mac" => $device->mac,
|
||||
"serial" => $device->serial,
|
||||
"price" => $device->price != "0.0" ? $device->price : $device->devicetype->price,
|
||||
"power" => $device->power != "0.0" ? $device->power : $device->devicetype->power,
|
||||
"backup" => $device->last_config_backup ? (time() - $device->last_config_backup <= 172800 ? 'ok' : 'aged') : 'na',
|
||||
];
|
||||
}
|
||||
|
||||
die(json_encode($data));
|
||||
break;
|
||||
header('Content-Type: application/json');
|
||||
die(json_encode($this->getDevices()));
|
||||
case "getconfig":
|
||||
$return = $this->getConfig($id, $format, $filename);
|
||||
$this->getConfig($id, $format, $filename);
|
||||
break;
|
||||
case "createconfig":
|
||||
$return = $this->createConfig($ip);
|
||||
$this->createConfig($ip);
|
||||
break;
|
||||
case "getoltinfo":
|
||||
$return = $this->getoltInfo($ip, $portid, $adv);
|
||||
$this->getoltInfo($ip, $portid, $adv);
|
||||
break;
|
||||
case "getontinfo":
|
||||
$return = $this->getontInfo($ip, $portid, $ont);
|
||||
$this->getontInfo($ip, $portid, $ont);
|
||||
break;
|
||||
case "getontinfomac":
|
||||
$return = $this->getontInfoMac($ip, $portid, $ont);
|
||||
$this->getontInfoMac($ip, $portid, $ont);
|
||||
break;
|
||||
case "changeoltsplitter":
|
||||
$return = $this->changeoltSplitter($id, $portid, $ports);
|
||||
$this->changeoltSplitter($id, $portid, $ports);
|
||||
break;
|
||||
default:
|
||||
$return = false;
|
||||
@@ -352,7 +345,7 @@ class DeviceController extends mfBaseController
|
||||
$returnUrl = "Device";
|
||||
$returnAction = "Detail";
|
||||
$returnVariables['id'] = $id;
|
||||
return $this->redirect($returnUrl, $returnAction, $returnVariables, $returnAnker);
|
||||
$this->redirect($returnUrl, $returnAction, $returnVariables);
|
||||
}
|
||||
|
||||
private function changeoltSplitter($id, $portid, $ports)
|
||||
@@ -390,4 +383,54 @@ class DeviceController extends mfBaseController
|
||||
exit;
|
||||
}
|
||||
|
||||
private function getDevices()
|
||||
{
|
||||
$devices = DeviceModel::getAll();
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$locationText = "";
|
||||
$locationUrl = "";
|
||||
|
||||
if (trim($device->pop->name)) {
|
||||
$locationText = $device->pop->name;
|
||||
$locationUrl = self::getUrl("Pop", "Detail", ["id" => $device->pop->id]);
|
||||
} else if (trim($device->addr_street)) {
|
||||
$locationText = $device->addr_street . " " . $device->addr_number . ", " . $device->addr_zip . " " . $device->addr_city;
|
||||
$locationUrl = "http://maps.google.com/?q=" . $locationText;
|
||||
} else if (trim($device->gps_lat)) {
|
||||
$locationText = $device->gps_lat . " , " . $device->gps_long;
|
||||
$locationUrl = "http://maps.google.com/?q=" . $locationText;
|
||||
}
|
||||
|
||||
$backup = 'na';
|
||||
if ($device->last_config_backup) {
|
||||
if (time() - $device->last_config_backup <= 172800) {
|
||||
$backup = 'ok';
|
||||
if ($device->autobackup==1) {
|
||||
$backup = 'auto';
|
||||
}
|
||||
} else {
|
||||
$backup = 'aged';
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
"id" => $device->id,
|
||||
"name" => $device->name,
|
||||
"devicetype" => $device->devicetype->name,
|
||||
"devicemanufactor" => $device->devicetype->devicemanufactor->name,
|
||||
"locationText" => $locationText,
|
||||
"locationUrl" => $locationUrl,
|
||||
"ip" => $device->ip,
|
||||
"mac" => $device->mac,
|
||||
"serial" => $device->serial,
|
||||
"price" => $device->price != "0.00" ? $device->price : $device->devicetype->price,
|
||||
"power" => $device->power != "0.00" ? intval($device->power) : intval($device->devicetype->power),
|
||||
"backup" => $backup,
|
||||
];
|
||||
}
|
||||
|
||||
return $data ?? [];
|
||||
}
|
||||
|
||||
}
|
||||
72
public/js/pages/Device/Device.js
Normal file
72
public/js/pages/Device/Device.js
Normal file
@@ -0,0 +1,72 @@
|
||||
Vue.component('Device', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table
|
||||
:fetch-url="window['TT_CONFIG']['DEVICE_API_URL'] + '?do=getDevices'"
|
||||
:config="DeviceTableConfig"
|
||||
small ref="table" excel-export
|
||||
>
|
||||
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/add'">
|
||||
<i class="fas fa-plus"></i>
|
||||
Device hinzufügen
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-slot:name="{ row }">
|
||||
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Detail?id=' + row.id">{{row.name}}</a>
|
||||
</template>
|
||||
|
||||
<template v-slot:locationtext="{ row }">
|
||||
<a target="_blank" :href="row['locationUrl']">{{row.locationText}}</a>
|
||||
</template>
|
||||
|
||||
<template v-slot:actions="{ row }">
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
|
||||
<a :href="window['TT_CONFIG']['BASE_URL'] +'/delete/?id=' + row.id" onclick="if(!confirm('Device wirklich löschen?')) return false;" class="text-danger" title="Löschen"><i class="fas fa-trash "></i></a>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
</div>
|
||||
`,
|
||||
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
DeviceTableConfig: {
|
||||
key: 'DeviceTable',
|
||||
tableHeader: 'Device-Liste',
|
||||
defaultPageSize: 25,
|
||||
headers: [
|
||||
{ text: 'Device Name', key: 'name', sortable: true, class: 'text-nowrap', priority: 10 },
|
||||
{ text: 'Hersteller', key: 'devicemanufactor', filter: 'select',class: 'text-nowrap text-center', filterOptions: window.TT_CONFIG["DEVICE_MANUFACTURERS"] },
|
||||
{ text: 'Geräte Typ', key: 'devicetype', filter: 'autocomplete', class: 'text-nowrap text-center', filterOptions: window.TT_CONFIG["DEVICE_TYPES"] , priority: 7},
|
||||
{ text: 'Pop/Adresse', key: 'locationText', filter: 'search' , class: 'text-nowrap text-center'},
|
||||
{ text: 'IP-Adresse', key: 'ip', filter: 'search',class: 'text-center', priority: 8},
|
||||
{ text: 'Mac-Adresse', key: 'mac', filter: 'search',class: 'text-center' },
|
||||
{ text: 'Seriennummer', key: 'serial', filter: 'search',class: 'text-center' },
|
||||
{ text: 'Preis', key: 'price', filter: 'numberRange',class: 'text-center', suffix: ' €' },
|
||||
{ text: 'max. Leistung', key: 'power', filter: 'numberRange',class: 'text-center', suffix: ' W' },
|
||||
{
|
||||
text: 'Backup',
|
||||
key: 'backup',
|
||||
filter: 'iconSelect',
|
||||
filterOptions: [
|
||||
{ value: 'ok', text: 'Configbackup aktuell', icon: 'fa-regular fa-circle-check text-success' },
|
||||
{ value: 'auto', text: 'Configbackup aktuell', icon: 'fa-regular fa-circle-a text-success' },
|
||||
{ value: 'aged', text: 'Letztes Configbackup älter als 48 Stunden', icon: 'fa-regular fa-circle-xmark' },
|
||||
{ value: 'na', text: 'N/A', icon: 'fa-regular fa-ban text-warning' }
|
||||
],
|
||||
priority: 6,
|
||||
sortable: false,
|
||||
},
|
||||
{text: 'Aktionen', key: 'actions', class: 'text-center', sortable: false, filter: false, priority: 9},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -20,7 +20,7 @@ Vue.component('Domain', {
|
||||
</button>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="checkDomainInput" placeholder="Neue Domain überprüfen">
|
||||
<input type="text" class="form-control" v-model="checkDomainInput" style="height: 100%" placeholder="Neue Domain überprüfen">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" @click="checkDomainAvailability">
|
||||
<template v-if="checkDomainLoading">
|
||||
@@ -83,7 +83,7 @@ Vue.component('Domain', {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in dnsRecordsModal.records">
|
||||
<td>{{ record.class }}</td>
|
||||
<td v-if="record">{{ record.class }}</td>
|
||||
<td>{{ record.type }} {{ record.pri ? '(' + record.pri + ')' : '' }}</td>
|
||||
<td>{{ record.host }}</td>
|
||||
<td>{{ record.value }}</td>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
|
||||
.tt-table-card {
|
||||
overflow: auto;
|
||||
/*overflow: auto;*/
|
||||
}
|
||||
|
||||
.tt-table-card .page-link {
|
||||
@@ -40,6 +40,7 @@
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.tt-table-pagination-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
@@ -48,28 +49,86 @@
|
||||
grid-gap: 4px;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.tt-table-pagination {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt-table-page-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-table-page-item.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tt-table-page-item.active {
|
||||
font-weight: bold;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tt-table-select {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.tt-table-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tt-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-table.table-sm > tbody > tr > td * {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.tt-table.table-sm > tbody > tr {
|
||||
line-height: 1.15 !important;
|
||||
}
|
||||
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/*table {*/
|
||||
/* width: 100%; !* Ensure table takes full available width *!*/
|
||||
/* table-layout: auto; !* Crucial for automatic column scaling *!*/
|
||||
/*}*/
|
||||
|
||||
/*td {*/
|
||||
/* white-space: nowrap; !* Prevent text from wrapping within cells *!*/
|
||||
/* overflow: hidden; !* Hide overflowing content *!*/
|
||||
/* text-overflow: ellipsis; !* Show ellipsis (...) for long content *!*/
|
||||
/*}*/
|
||||
|
||||
.tt-table-top-pagination-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding-bottom: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between
|
||||
}
|
||||
|
||||
@media (max-width: 486px) {
|
||||
.tt-table-top-pagination-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tt-table-top-pagination-container > * {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tt-table-top-pagination-container > *:first-child {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,16 @@ Vue.component('tt-autocomplete', {
|
||||
template: `
|
||||
<div class="form-group">
|
||||
<slot name="prepend"></slot>
|
||||
<label :for="label">{{ label }}</label>
|
||||
<label v-if="label" :for="label">{{ label }}</label>
|
||||
<div class="autocomplete position-relative">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
class="form-control form-control-sm"
|
||||
v-model="displayValue"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
style="padding-right: 30px;"
|
||||
:style="{'padding-right': $slots.append ? '30px' : '0'}"
|
||||
/>
|
||||
<slot name="append"></slot>
|
||||
|
||||
@@ -20,9 +20,9 @@ Vue.component('tt-autocomplete', {
|
||||
|
||||
<div v-show="isLoading" class="loader"></div>
|
||||
|
||||
<template v-show="showSuggestions && items.length > 0 && isLoading !== true">
|
||||
<template v-show="showSuggestions && displayingItems.length > 0 && isLoading !== true">
|
||||
<li
|
||||
v-for="(item, index) in items.slice(0, 10)"
|
||||
v-for="(item) in displayingItems.slice(0, 10)"
|
||||
:key="item.value"
|
||||
:class="{'active': value === item.value}"
|
||||
class="dropdown-item"
|
||||
@@ -32,15 +32,16 @@ Vue.component('tt-autocomplete', {
|
||||
{{ item.text }}
|
||||
</li>
|
||||
<!-- display more search results available define it more precisely in German -->
|
||||
<li v-show="items.length > 10" class="dropdown-item disabled">
|
||||
<li v-show="displayingItems.length > 10" class="dropdown-item disabled">
|
||||
Mehr Suchergebnisse vorhanden. Bitte genauer eingeben
|
||||
</li>
|
||||
</template>
|
||||
<li v-show="items.length === 0 && isLoading === false && displayValue.length > 3" class="dropdown-item disabled">
|
||||
<li v-show="displayingItems.length === 0 && isLoading === false && displayValue.length > 3"
|
||||
class="dropdown-item disabled">
|
||||
Keine Suchergebnisse vorhanden.
|
||||
</li>
|
||||
<li v-show="displayValue.length < 3" class="dropdown-item disabled">
|
||||
Bitte mindestens 3 Zeichen eingeben
|
||||
Bitte mindestens 3 Zeichen eingeben
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -54,19 +55,29 @@ Vue.component('tt-autocomplete', {
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
},
|
||||
apiUrl: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (this.value) {
|
||||
if (this.value && this.apiUrl) {
|
||||
const response = await axios.get(`${this.apiUrl}&autocomplete=1&searchedID=${this.value}`);
|
||||
this.displayValue = response.data[0].text;
|
||||
} else if (this.value) {
|
||||
const selectedItem = this.items.find(item => item.value === this.value);
|
||||
this.displayValue = selectedItem ? selectedItem.text : '';
|
||||
}
|
||||
|
||||
console.log(this.$slots.append ? 'append' : 'no append');
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
displayingItems: [],
|
||||
displayValue: '',
|
||||
isLoading: false,
|
||||
showSuggestions: false,
|
||||
@@ -76,7 +87,7 @@ Vue.component('tt-autocomplete', {
|
||||
},
|
||||
watch: {
|
||||
value(newValue) {
|
||||
const selectedItem = this.items.find(item => item.value === newValue);
|
||||
const selectedItem = this.displayingItems.find(item => item.value === newValue);
|
||||
this.displayValue = selectedItem ? selectedItem.text : '';
|
||||
},
|
||||
apiUrl() {
|
||||
@@ -86,6 +97,7 @@ Vue.component('tt-autocomplete', {
|
||||
methods: {
|
||||
onInput(event) {
|
||||
this.displayValue = event.target.value;
|
||||
this.$emit('input', undefined);
|
||||
this.fetchSuggestions();
|
||||
},
|
||||
onFocus() {
|
||||
@@ -97,7 +109,28 @@ Vue.component('tt-autocomplete', {
|
||||
}, 200);
|
||||
},
|
||||
fetchSuggestions() {
|
||||
if (!this.apiUrl || this.displayValue.length < 3) return;
|
||||
if (this.displayValue.length < 3) {
|
||||
this.$set(this, 'displayingItems', []);
|
||||
return this.displayingItems = [];
|
||||
}
|
||||
|
||||
if (!this.apiUrl) {
|
||||
|
||||
const isNegated = this.displayValue.startsWith('!');
|
||||
const substrings = (isNegated ? this.displayValue.slice(1) : this.displayValue).split(' ').map(s => s.toLowerCase());
|
||||
this.displayingItems = this.items.filter(item => {
|
||||
let substringMatch = true;
|
||||
for (var k = 0, klen = substrings.length; k < klen; ++k) {
|
||||
if (!item.text.toLowerCase().includes(substrings[k])) {
|
||||
substringMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (isNegated && !substringMatch) || (!isNegated && substringMatch);
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
clearTimeout(this.fetchSuggestionsDebounceTimer);
|
||||
@@ -107,9 +140,9 @@ Vue.component('tt-autocomplete', {
|
||||
setTimeout(async () => {
|
||||
const response = await axios.get(`${this.apiUrl}&autocomplete=1&q=${this.displayValue}`);
|
||||
if (response.data?.status === 'error') {
|
||||
this.items = [];
|
||||
this.displayingItems = [];
|
||||
} else {
|
||||
this.items = response.data
|
||||
this.displayingItems = response.data
|
||||
}
|
||||
this.isLoading = false;
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ Vue.component('tt-icon-select', {
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// Dynamically add CSS to disable default dropdown caret
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `.tt-select .dropdown-toggle::after { display: none; }`;
|
||||
document.body.appendChild(style);
|
||||
if (! document.getElementById('tt-icon-select-style')) {
|
||||
// Dynamically add CSS to disable default dropdown caret
|
||||
const style = document.createElement('style');
|
||||
style.id = 'tt-icon-select-style';
|
||||
style.innerHTML = `.tt-select .dropdown-toggle::after { display: none; }`;
|
||||
document.body.appendChild(style);
|
||||
}
|
||||
|
||||
document.addEventListener('click', this.handleClick);
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ Vue.component('tt-number-range', {
|
||||
}
|
||||
}
|
||||
}, template: `
|
||||
<div style="display:grid;grid-template-columns: 75px 25px 75px;grid-gap: 4px;justify-content: center">
|
||||
<div style="display:grid;grid-template-columns: minmax(35px, 75px) minmax(20px, 25px) minmax(35px, 75px);;grid-gap: 4px;justify-content: center">
|
||||
<slot name="prepend"></slot>
|
||||
<input type="number"
|
||||
style="-webkit-appearance: none;
|
||||
-moz-appearance: textfield;"
|
||||
class="form-control form-control-sm"
|
||||
v-model.number="inputValueFrom"
|
||||
@input="updateValue"
|
||||
@@ -60,3 +62,5 @@ Vue.component('tt-number-range', {
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
/**
|
||||
* @typedef {Object} ttTableColumnConfig
|
||||
* @property {string} text - The display text of the column.
|
||||
* @property {string} key - The unique key of the column.
|
||||
* @property {string} filter - Indicates if filtering is enabled for the column.
|
||||
* @property {boolean} sortable - Indicates if sorting is enabled for the column.
|
||||
* @property {string} class - The CSS class(es) applied to the column.
|
||||
* @property {string} text - The display text of the column header.
|
||||
* @property {string} key - The unique key of the column (used for data access and filtering).
|
||||
* @property {string} [filter] - (Optional) Determines the type of filter applied to the column. Default is 'search'.
|
||||
* Possible values:
|
||||
* - 'search': Basic text search (case-insensitive).
|
||||
* - 'iconSelect': Filter with icons and associated text (requires 'filterOptions').
|
||||
* - 'numberRange': Filter with numeric ranges (e.g., ">5", "10-20", etc.).
|
||||
* - 'select': Dropdown filter with predefined options (requires 'filterOptions').
|
||||
* - 'date': Date range filter.
|
||||
* - 'false': Disables filtering for the column.
|
||||
* @property {Array} [filterOptions] - (Optional) An array of filter options for 'iconSelect' or 'select' filters.
|
||||
* Each option should be an object with 'text' (display text) and 'value' (filter value) properties.
|
||||
* For 'iconSelect', an additional 'icon' property (CSS class for the icon) is required.
|
||||
* @property {boolean} [sortable=true] - (Optional) Indicates whether the column is sortable. Default is true.
|
||||
* @property {string} [class] - (Optional) Additional CSS classes to apply to the column.
|
||||
* @property {string} [suffix] - (Optional) Additional CSS classes to apply to the column.
|
||||
* @property {string} [prefix] - (Optional) Additional CSS classes to apply to the column.
|
||||
* */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ttTableConfig
|
||||
* @property {string} key - A unique key for the table instance (used for saving/loading settings).
|
||||
* @property {string} tableHeader - The main header text displayed above the table.
|
||||
* @property {ttTableColumnConfig[]} headers - An array of column configuration objects (see `ttTableColumnConfig` typedef).
|
||||
* @property {Function} [customRowClass] - (Optional) A function that returns a CSS class string based on row data.
|
||||
* @property {Function} [expandCondition] - (Optional) A function that determines if a row is expandable.
|
||||
* @property {number} [defaultPageSize=10] - (Optional) The default number of rows to display per page.
|
||||
* @property {Function} [customExcelProcessor] - (Optional) A function to preprocess row data before exporting to Excel.
|
||||
*/
|
||||
|
||||
|
||||
Vue.component('tt-table-pagination', {
|
||||
props: {
|
||||
pagination: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({page: 1, per_page: 10, total_rows: 0, filtered_available: 0, total_pages: 1})
|
||||
},
|
||||
reverse: {type: Boolean, default: false}
|
||||
},
|
||||
computed: {
|
||||
}, reverse: {type: Boolean, default: false}
|
||||
}, computed: {
|
||||
pagesToDisplay() {
|
||||
const range = 2;
|
||||
const start = Math.max(this.pagination.page - range, 1);
|
||||
@@ -26,65 +48,61 @@ Vue.component('tt-table-pagination', {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages.length === 0 ? [1] : pages;
|
||||
},
|
||||
pageInfoText() {
|
||||
}, pageInfoText() {
|
||||
const start = Math.min(this.pagination.page * this.pagination.per_page - this.pagination.per_page + 1, this.pagination.total_rows);
|
||||
const end = Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total_rows);
|
||||
const total = this.pagination.total_rows === this.pagination.filtered_available
|
||||
? this.pagination.total_rows
|
||||
: `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
|
||||
const total = this.pagination.total_rows === this.pagination.filtered_available ? this.pagination.total_rows : `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
|
||||
return `${start} bis ${end} von ${total}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
}, methods: {
|
||||
fetchRows(page) {
|
||||
this.$emit('fetch-rows', page);
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="tt-table-pagination-container">
|
||||
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
|
||||
}, template: `
|
||||
<div class="tt-table-pagination-container">
|
||||
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
|
||||
<span class="tt-table-text-center" v-text="pageInfoText"
|
||||
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 1 }"></span>
|
||||
|
||||
|
||||
<ul class="pagination tt-table-pagination" :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
|
||||
<li class="page-item tt-table-page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">First</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }">
|
||||
<a class="page-link"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"
|
||||
href="#"
|
||||
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item"
|
||||
v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="pagination tt-table-pagination" :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
|
||||
<li class="page-item tt-table-page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">First</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }">
|
||||
<a class="page-link"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"
|
||||
href="#"
|
||||
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item"
|
||||
v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<span class="tt-table-text-center" :style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 2 }">Einträge pro Seite</span>
|
||||
<span class="tt-table-text-center"
|
||||
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 2 }">Einträge pro Seite</span>
|
||||
|
||||
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)"
|
||||
class="form-control form-control-sm tt-table-select"
|
||||
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)"
|
||||
class="form-control form-control-sm tt-table-select"
|
||||
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -92,7 +110,7 @@ Vue.component('tt-table-pagination', {
|
||||
Vue.component('tt-table', {
|
||||
template: `
|
||||
<div class="card tt-table-card" v-if="columns && pagination">
|
||||
<div class="card-body">
|
||||
<div class="card-body" ref="tableContainer">
|
||||
<!-- Top Buttons -->
|
||||
<div
|
||||
style="display:grid; grid-template-columns: auto auto auto auto auto; grid-gap: 8px; padding-bottom: 8px">
|
||||
@@ -100,58 +118,30 @@ Vue.component('tt-table', {
|
||||
</div>
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Page navigation">
|
||||
<div
|
||||
style="display:grid; grid-template-columns: 1fr 1fr;padding-bottom: 8px;align-items:center; justify-content: space-between">
|
||||
<div class="tt-table-top-pagination-container">
|
||||
<!-- if excelExport is true, show the export button fontawesome icon excel -->
|
||||
<div style="display:flex;align-items: center;">
|
||||
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen"
|
||||
@click="resetTable" class="fa-solid fa-trash-undo"
|
||||
style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i>
|
||||
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
|
||||
@click="resetTable" class="fas fa-times cursor-pointer text-danger"
|
||||
style="font-size: 24px;margin-right: 6px;cursor: pointer; color: var(--orange)"></i>
|
||||
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel"
|
||||
style="font-size: 24px;margin-left: 8px;cursor: pointer; color: var(--success)"></i>
|
||||
style="font-size: 24px;margin-right: 6px;cursor: pointer; color: var(--success)"></i>
|
||||
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
|
||||
</div>
|
||||
|
||||
<div v-if="pagination && typeof pagination.total_rows === 'number'"
|
||||
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
|
||||
<ul class="pagination" style="margin: 0">
|
||||
<li class="page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">First</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item" v-for="pageNumber in pagesToDisplay"
|
||||
v-bind:class="{ 'active disabled': pageNumber === pagination.page }">
|
||||
<a class="page-link" v-bind:class="{ 'active disabled': pageNumber === pagination.page }" href="#"
|
||||
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
|
||||
</li>
|
||||
<li class="page-item" v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-center"
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.filtered_available)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.filtered_available) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
<span class="text-center">Einträge pro Seite</span>
|
||||
</div>
|
||||
<tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
|
||||
v-if="pagination"></tt-table-pagination>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Table -->
|
||||
<table
|
||||
ref="table"
|
||||
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]">
|
||||
<thead style="border-width: 2px">
|
||||
<tr>
|
||||
<th scope="col" v-for="column in columns"
|
||||
:ref="'table_header_'+column.key"
|
||||
v-if="!hiddenColumns.includes(column.key)"
|
||||
:style="'vertical-align: top; text-align: center;' + (column.filter === 'dateRange' ? 'min-width: 260px;' : '')">
|
||||
<div style="text-align:center; white-space: nowrap;word-break: keep-all;"
|
||||
:style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
|
||||
@@ -169,6 +159,10 @@ Vue.component('tt-table', {
|
||||
<tt-select v-else-if="column.filter === 'select'"
|
||||
:options="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
|
||||
v-model="filters[column.key]"></tt-select>
|
||||
|
||||
<tt-autocomplete v-else-if="column.filter === 'autocomplete'"
|
||||
:items="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
|
||||
v-model="filters[column.key]"> </tt-autocomplete>
|
||||
<tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"></tt-date-picker>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -181,16 +175,18 @@ Vue.component('tt-table', {
|
||||
style="height: 150px">
|
||||
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
|
||||
</tr>
|
||||
<template v-for="(row) in (ssr === false ? computedRows : rows)">
|
||||
<template v-for="(row, index) in (ssr === false ? computedRows : rows)">
|
||||
<tr :class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''"
|
||||
@click="$emit('row-click', row)"
|
||||
>
|
||||
<template v-for="(column, key) in columns">
|
||||
<template v-for="(column, key) in columns" v-if="!hiddenColumns.includes(column.key)">
|
||||
<td :class="{ 'text-center': column.filter === 'iconSelect', [columns[key].class]: true }">
|
||||
<!-- If td is first of row then check isExpanded and display fas.fa-chevron-right or fas.fa-chevron-down with cursor pointer -->
|
||||
<i v-if="key === Object.keys(columns)[0] && $scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row))"
|
||||
@click.stop="toggleExpand(row.id)"
|
||||
:class="isExpanded(row.id) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"
|
||||
<i v-if="key === Object.keys(columns)[0] &&
|
||||
($scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row)) || hiddenColumns.length > 0)"
|
||||
|
||||
@click.stop="toggleExpand(index)"
|
||||
:class="isExpanded(index) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"
|
||||
style="cursor: pointer;font-size: 14px;padding-right: 8px;user-select: none"></i>
|
||||
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
|
||||
<span
|
||||
@@ -199,13 +195,35 @@ Vue.component('tt-table', {
|
||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<span v-else
|
||||
v-html="row[key] === null || typeof row[key] === 'undefined' ? null : row[key]?.toString()?.replace('\\n', '<br>')"></span>
|
||||
v-html="(column.prefix) + (row[key] === null || typeof row[key] === 'undefined' ? '' : row[key]?.toString()?.replace('\\\\n', '<br>')) + (column.suffix )"></span>
|
||||
|
||||
</slot>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="isExpanded(row.id) && $scopedSlots.expandedRow">
|
||||
<tr v-if="isExpanded(index) && ($scopedSlots.expandedRow|| hiddenColumns.length > 0)">
|
||||
<td :colspan="Object.keys(columns).length">
|
||||
|
||||
<!-- display ul with li for each column in hiddenColumns with slot name key.toLowerCase() or span with value of row[key] -->
|
||||
<ul v-if="hiddenColumns.length > 0" style="list-style-type: none;padding: 0;margin: 0;">
|
||||
<li v-for="(column, key) in columns" :key="'hiddenColumn-'+key">
|
||||
<template v-if="hiddenColumns.includes(key)">
|
||||
<strong>{{ column.text }}:</strong>
|
||||
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
|
||||
|
||||
<span
|
||||
v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
|
||||
<i v-else-if="column.filter === 'iconSelect'"
|
||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<span v-else
|
||||
v-html="(column.prefix) + (row[key] === null || typeof row[key] === 'undefined' ? '' : row[key]?.toString()?.replace('\\\\n', '<br>')) + (column.suffix )">
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<slot name="expandedRow" :row="row"></slot>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -234,7 +252,6 @@ Vue.component('tt-table', {
|
||||
return {
|
||||
window: window,
|
||||
moment: window.moment,
|
||||
XLSX: window.XLSX,
|
||||
loading: false,
|
||||
rows: null,
|
||||
rawRows: null,
|
||||
@@ -244,11 +261,13 @@ Vue.component('tt-table', {
|
||||
disableDebounce: false,
|
||||
latestFetchTimestamp: null,
|
||||
order: {
|
||||
key: null,
|
||||
order: 'asc' // default sort order
|
||||
key: null, order: 'asc' // default sort order
|
||||
},
|
||||
expandedRows: {},
|
||||
isInitialised: false
|
||||
isInitialised: false,
|
||||
hiddenColumns: [],
|
||||
originalColumnWidths: {},
|
||||
originalTableWidth: null
|
||||
};
|
||||
},
|
||||
|
||||
@@ -274,6 +293,8 @@ Vue.component('tt-table', {
|
||||
* @async
|
||||
*/
|
||||
async fetchData(page = 0) {
|
||||
this.expandedRows = {};
|
||||
|
||||
try {
|
||||
if (this.ssr === false) {
|
||||
const response = await axios.get(this.fetchUrl);
|
||||
@@ -332,8 +353,7 @@ Vue.component('tt-table', {
|
||||
} else {
|
||||
await this.fetchData(page); // Directly call fetchData without debounce
|
||||
}
|
||||
},
|
||||
saveSettingsToLocalStorage() {
|
||||
}, saveSettingsToLocalStorage() {
|
||||
if (this.isInitialised === false) return;
|
||||
|
||||
const filters = Object.entries(this.filters).reduce((acc, [key, value]) => {
|
||||
@@ -353,21 +373,17 @@ Vue.component('tt-table', {
|
||||
filters,
|
||||
paginationPerPage: this.pagination.per_page,
|
||||
order: this.order.key ? this.order : undefined,
|
||||
expandedRows: this.expandedRows
|
||||
}));
|
||||
},
|
||||
parseSettingsFromLocalStorage() {
|
||||
}, parseSettingsFromLocalStorage() {
|
||||
const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}');
|
||||
if (settings) {
|
||||
this.disableDebounce = true;
|
||||
this.filters = settings.filters || {};
|
||||
this.pagination.per_page = parseInt(settings.paginationPerPage) || this.config.defaultPageSize || 10;
|
||||
this.order = settings.order || {key: null, order: 'asc'};
|
||||
this.expandedRows = settings.expandedRows || {};
|
||||
}
|
||||
return !!settings;
|
||||
},
|
||||
setOrder(key) {
|
||||
}, setOrder(key) {
|
||||
if (this.order.key === key) {
|
||||
// if current order is desc then set key to null
|
||||
if (this.order.order === 'desc') {
|
||||
@@ -382,16 +398,12 @@ Vue.component('tt-table', {
|
||||
this.order.key = key;
|
||||
this.order.order = 'asc';
|
||||
}
|
||||
},
|
||||
getSortIconClass(key) {
|
||||
}, getSortIconClass(key) {
|
||||
if (this.order.key === key) {
|
||||
return this.order.order === 'asc' ? 'fa fa-sort-asc' : 'fa fa-sort-desc';
|
||||
}
|
||||
return 'fa fa-sort'; // default icon when not sorted
|
||||
},
|
||||
async exportToExcel() {
|
||||
|
||||
// create script and await downloading: /plugins/xlsx/xlsx.min.js
|
||||
}, async exportToExcel() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/plugins/xlsx/xlsx.min.js';
|
||||
@@ -400,29 +412,41 @@ Vue.component('tt-table', {
|
||||
document.head.appendChild(script);
|
||||
})
|
||||
|
||||
function defaultExcelProcessor(rows) {
|
||||
return rows.map(row => {
|
||||
const parsedRow = {};
|
||||
|
||||
const wb = this.XLSX.utils.book_new();
|
||||
for (const key in row) {
|
||||
if (!this.columns[key]) continue;
|
||||
|
||||
let data = typeof this.config.customExcelProcessor === 'function' ? this.config.customExcelProcessor(this.rawRows) : JSON.parse(JSON.stringify(this.rawRows));
|
||||
if (this.columns[key] && this.columns[key].filter === 'iconSelect') {
|
||||
parsedRow[this.columns[key].text] = this.columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text;
|
||||
} else if (this.columns[key] && this.columns[key].filter === 'date') {
|
||||
parsedRow[this.columns[key].text] = this.moment(row[key]).format('DD.MM.YYYY HH:mm');
|
||||
} else {
|
||||
console.log(key)
|
||||
parsedRow[this.columns[key].text] = row[key];
|
||||
}
|
||||
|
||||
// convert all columns with date with momentjs
|
||||
|
||||
data = data.map(row => {
|
||||
for (const key in row) {
|
||||
if (this.columns[key].filter === 'date') {
|
||||
row[key] = this.moment(row[key]).format('DD.MM.YYYY HH:mm');
|
||||
}
|
||||
}
|
||||
return row;
|
||||
});
|
||||
return parsedRow;
|
||||
});
|
||||
}
|
||||
|
||||
const ws = this.XLSX.utils.json_to_sheet(data);
|
||||
const wb = this.window.XLSX.utils.book_new();
|
||||
|
||||
|
||||
let data = typeof this.config.customExcelProcessor === 'function' ?
|
||||
this.config.customExcelProcessor(JSON.parse(JSON.stringify(this.rawRows))) :
|
||||
defaultExcelProcessor.call(this, JSON.parse(JSON.stringify(this.rawRows)));
|
||||
|
||||
const ws = this.window.XLSX.utils.json_to_sheet(data);
|
||||
|
||||
for (const cell in ws) {
|
||||
if (cell.startsWith('!')) continue; // Skip non-cell properties like '!ref'
|
||||
const cellValue = ws[cell].v;
|
||||
if (cellValue.toString().includes('\n')) {
|
||||
console.log('Found newline in cell:', cell, cellValue);
|
||||
// console.log('Found newline in cell:', cell, cellValue);
|
||||
if (!ws[cell].s) ws[cell].s = {};
|
||||
ws[cell].s.alignment = {wrapText: true, vertical: 'center'};
|
||||
} else {
|
||||
@@ -430,26 +454,54 @@ Vue.component('tt-table', {
|
||||
}
|
||||
}
|
||||
|
||||
this.XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
this.XLSX.writeFile(wb, 'export.xlsx');
|
||||
},
|
||||
resetTable() {
|
||||
this.window.XLSX.utils.book_append_sheet(wb, ws, "Export");
|
||||
this.window.XLSX.writeFile(wb, 'export.xlsx');
|
||||
}, resetTable() {
|
||||
this.$emit('reset-table');
|
||||
this.filters = {};
|
||||
this.order = {key: null, order: 'asc'};
|
||||
this.expandedRows = {};
|
||||
this.disableDebounce = true;
|
||||
window.notify('success', 'Filter zurückgesetzt');
|
||||
},
|
||||
toggleExpand(index) {
|
||||
}, toggleExpand(index) {
|
||||
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true);
|
||||
},
|
||||
isExpanded(index) {
|
||||
}, isExpanded(index) {
|
||||
return !!this.expandedRows[index];
|
||||
},
|
||||
async handleResponsiveColumns() {
|
||||
const tableContainerComputedStyle = window.getComputedStyle(this.$refs.tableContainer);
|
||||
let viewportWidth = this.$refs.tableContainer.offsetWidth -
|
||||
parseInt(tableContainerComputedStyle.paddingLeft) -
|
||||
parseInt(tableContainerComputedStyle.paddingRight);
|
||||
this.hiddenColumns = []
|
||||
await this.$nextTick()
|
||||
|
||||
// Initialize original column widths only if empty
|
||||
if (Object.keys(this.originalColumnWidths).length === 0) {
|
||||
for (let i = 0; i < Object.keys(this.columns).length; i++) {
|
||||
const column = Object.keys(this.columns)[i]
|
||||
this.originalColumnWidths[column] = this.$refs[`table_header_${column}`][0].offsetWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Create an array of columns sorted by priority
|
||||
const columns = Object.values(this.columns).sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Iterate over all columns and check if the viewport width is smaller than the column width and hide the column
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
viewportWidth -= this.originalColumnWidths[columns[i].key];
|
||||
|
||||
if (i === 0) continue;
|
||||
|
||||
else if (viewportWidth < 0) {
|
||||
this.hiddenColumns.push(columns[i].key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}, watch: {
|
||||
filters: {
|
||||
handler: function (newVal, oldVal) {
|
||||
handler: function () {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
if (this.ssr) {
|
||||
@@ -457,17 +509,15 @@ Vue.component('tt-table', {
|
||||
}
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
'pagination.per_page': {
|
||||
}, 'pagination.per_page': {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
if (newVal === oldVal) return
|
||||
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
order: {
|
||||
handler: function (newVal, oldVal) {
|
||||
}, order: {
|
||||
handler: function () {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
if (this.ssr) {
|
||||
@@ -475,13 +525,26 @@ Vue.component('tt-table', {
|
||||
}
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
expandedRows: {
|
||||
handler: function (newVal, oldVal) {
|
||||
}, expandedRows: {
|
||||
handler: function () {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
rows: {
|
||||
handler: async function () {
|
||||
this.$nextTick(() => {
|
||||
this.handleResponsiveColumns()
|
||||
})
|
||||
}, deep: true
|
||||
},
|
||||
computedRows: {
|
||||
handler: async function () {
|
||||
this.$nextTick(() => {
|
||||
this.handleResponsiveColumns()
|
||||
})
|
||||
}, deep: true
|
||||
}
|
||||
}, computed: {
|
||||
/**
|
||||
@@ -489,18 +552,23 @@ Vue.component('tt-table', {
|
||||
* @return {ttTableColumnConfig} The columns configuration.
|
||||
*/
|
||||
columns() {
|
||||
let i = this.config.headers.length;
|
||||
return this.config.headers.reduce((columns, column) => {
|
||||
if (!column.key) {
|
||||
console.warn('WARN: tt-table: Column text or key is not defined:', column);
|
||||
// console.warn('WARN: tt-table: Column text or key is not defined:', column);
|
||||
return columns; // Continue to the next iteration without modifying the accumulator
|
||||
}
|
||||
|
||||
columns[column.key] = {
|
||||
text: column.text,
|
||||
key: column.key,
|
||||
filter: column.filter !== undefined ? column.filter : 'search',
|
||||
filterOptions: column.filterOptions || undefined,
|
||||
sortable: column.sortable !== undefined ? column.sortable : true,
|
||||
class: column.class !== undefined ? column.class : ''
|
||||
class: column.class !== undefined ? column.class : '',
|
||||
prefix: column.prefix || '',
|
||||
suffix: column.suffix || '',
|
||||
priority: column.priority + 100 || i--
|
||||
};
|
||||
return columns;
|
||||
}, {});
|
||||
@@ -526,10 +594,9 @@ Vue.component('tt-table', {
|
||||
}
|
||||
|
||||
return pagesArray.length === 0 ? [1] : pagesArray;
|
||||
},
|
||||
computedRows() {
|
||||
}, computedRows() {
|
||||
if (!this.rawRows || this.ssr === true) return null;
|
||||
console.time('Filtering and pagination');
|
||||
// console.time('Filtering and pagination');
|
||||
|
||||
function handleRangeFilter(filter, value) {
|
||||
if (filter[0] === '<') {
|
||||
@@ -567,6 +634,7 @@ Vue.component('tt-table', {
|
||||
const header = headers[filter];
|
||||
|
||||
const filterValue = filters[filter];
|
||||
if (!filterValue) continue;
|
||||
if (filterValue === '') continue;
|
||||
if (filterValue === "!") continue;
|
||||
|
||||
@@ -596,7 +664,7 @@ Vue.component('tt-table', {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
} else if (header.filter === 'select' || header.filter === 'iconSelect') {
|
||||
} else if (header.filter === 'select' || header.filter === 'iconSelect' || header.filter === 'autocomplete') {
|
||||
if (filterValue === '') continue;
|
||||
if (filterValue !== row[header.key].toString()) {
|
||||
match = false;
|
||||
@@ -644,7 +712,7 @@ Vue.component('tt-table', {
|
||||
|
||||
}
|
||||
|
||||
console.timeEnd('Filtering and pagination');
|
||||
// console.timeEnd('Filtering and pagination');
|
||||
// Pagination and slice logic
|
||||
this.pagination.total_pages = Math.ceil(output.length / this.pagination.per_page);
|
||||
this.pagination.filtered_available = output.length;
|
||||
@@ -656,14 +724,12 @@ Vue.component('tt-table', {
|
||||
return this.rows;
|
||||
}
|
||||
|
||||
},
|
||||
async created() {
|
||||
}, async created() {
|
||||
if (this.config.hasOwnProperty('defaultPageSize') && this.config.defaultPageSize) {
|
||||
this.pagination = {page: 1, per_page: this.config.defaultPageSize, total_rows: null, total_pages: 1};
|
||||
}
|
||||
|
||||
|
||||
|
||||
this.parseSettingsFromLocalStorage()
|
||||
|
||||
if (!this.disableInitialFetch) {
|
||||
@@ -675,8 +741,24 @@ Vue.component('tt-table', {
|
||||
// if sticky is true then add style element to style thead sticky
|
||||
if (this.sticky) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `table thead th { position: sticky; top: 0; z-index: 1; background-color: white; }`;
|
||||
// use header#topnav as top to stick to but if window is resized then check if header#topnav height is changed
|
||||
|
||||
const headerHeight = document.querySelector('header#topnav')?.offsetHeight || 0;
|
||||
style.innerHTML = `table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
|
||||
style.id = 'tt-table-sticky-header';
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const headerHeight = document.querySelector('header#topnav')?.offsetHeight || 0;
|
||||
style.innerHTML = `table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
|
||||
})
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user