Reworked Device Table and added table features

This commit is contained in:
2024-05-27 18:07:30 +02:00
parent 3e5166313e
commit fa3c1b8766
9 changed files with 522 additions and 393 deletions

View File

@@ -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"); ?>

View File

@@ -17,11 +17,36 @@ class DeviceController extends mfBaseController
protected function indexAction() protected function indexAction()
{ {
$deviceManufacturers = array_map(function($manufacturer) {
return [
"text" => $manufacturer->name,
"value" => $manufacturer->name,
];
}, DevicemanufactorModel::getAll());
$this->layout()->setTemplate("Device/Index"); $deviceTypes = array_map(function($deviceType) {
$devices = DeviceModel::getAll(); return [
$this->layout()->set("devices", $devices); "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); $this->layout()->set("device", $device);
return $this->addAction(); $this->addAction();
} }
protected function saveAction() protected function saveAction()
@@ -253,57 +278,25 @@ class DeviceController extends mfBaseController
switch ($do) { switch ($do) {
case "getDevices": case "getDevices":
$devices = DeviceModel::getAll(); header('Content-Type: application/json');
die(json_encode($this->getDevices()));
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;
case "getconfig": case "getconfig":
$return = $this->getConfig($id, $format, $filename); $this->getConfig($id, $format, $filename);
break; break;
case "createconfig": case "createconfig":
$return = $this->createConfig($ip); $this->createConfig($ip);
break; break;
case "getoltinfo": case "getoltinfo":
$return = $this->getoltInfo($ip, $portid, $adv); $this->getoltInfo($ip, $portid, $adv);
break; break;
case "getontinfo": case "getontinfo":
$return = $this->getontInfo($ip, $portid, $ont); $this->getontInfo($ip, $portid, $ont);
break; break;
case "getontinfomac": case "getontinfomac":
$return = $this->getontInfoMac($ip, $portid, $ont); $this->getontInfoMac($ip, $portid, $ont);
break; break;
case "changeoltsplitter": case "changeoltsplitter":
$return = $this->changeoltSplitter($id, $portid, $ports); $this->changeoltSplitter($id, $portid, $ports);
break; break;
default: default:
$return = false; $return = false;
@@ -352,7 +345,7 @@ class DeviceController extends mfBaseController
$returnUrl = "Device"; $returnUrl = "Device";
$returnAction = "Detail"; $returnAction = "Detail";
$returnVariables['id'] = $id; $returnVariables['id'] = $id;
return $this->redirect($returnUrl, $returnAction, $returnVariables, $returnAnker); $this->redirect($returnUrl, $returnAction, $returnVariables);
} }
private function changeoltSplitter($id, $portid, $ports) private function changeoltSplitter($id, $portid, $ports)
@@ -390,4 +383,54 @@ class DeviceController extends mfBaseController
exit; 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 ?? [];
}
} }

View 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},
],
},
};
},
});

View File

@@ -20,7 +20,7 @@ Vue.component('Domain', {
</button> </button>
<div class="input-group"> <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"> <div class="input-group-append">
<button class="btn btn-primary" @click="checkDomainAvailability"> <button class="btn btn-primary" @click="checkDomainAvailability">
<template v-if="checkDomainLoading"> <template v-if="checkDomainLoading">
@@ -83,7 +83,7 @@ Vue.component('Domain', {
</thead> </thead>
<tbody> <tbody>
<tr v-for="record in dnsRecordsModal.records"> <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.type }} {{ record.pri ? '(' + record.pri + ')' : '' }}</td>
<td>{{ record.host }}</td> <td>{{ record.host }}</td>
<td>{{ record.value }}</td> <td>{{ record.value }}</td>

View File

@@ -22,7 +22,7 @@
.tt-table-card { .tt-table-card {
overflow: auto; /*overflow: auto;*/
} }
.tt-table-card .page-link { .tt-table-card .page-link {
@@ -40,6 +40,7 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
} }
.tt-table-pagination-wrapper { .tt-table-pagination-wrapper {
display: grid; display: grid;
grid-template-rows: auto auto; grid-template-rows: auto auto;
@@ -48,28 +49,86 @@
grid-gap: 4px; grid-gap: 4px;
justify-content: end; justify-content: end;
} }
.tt-table-pagination { .tt-table-pagination {
margin: 0; margin: 0;
} }
.tt-table-page-item { .tt-table-page-item {
cursor: pointer; cursor: pointer;
} }
.tt-table-page-item.disabled { .tt-table-page-item.disabled {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
} }
.tt-table-page-item.active { .tt-table-page-item.active {
font-weight: bold; font-weight: bold;
background-color: #007bff; background-color: #007bff;
color: white; color: white;
} }
.tt-table-select { .tt-table-select {
display: inline-block; display: inline-block;
width: auto; width: auto;
} }
.tt-table-text-center { .tt-table-text-center {
text-align: center; text-align: center;
} }
.tt-pointer { .tt-pointer {
cursor: 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;
}
} }

View File

@@ -2,16 +2,16 @@ Vue.component('tt-autocomplete', {
template: ` template: `
<div class="form-group"> <div class="form-group">
<slot name="prepend"></slot> <slot name="prepend"></slot>
<label :for="label">{{ label }}</label> <label v-if="label" :for="label">{{ label }}</label>
<div class="autocomplete position-relative"> <div class="autocomplete position-relative">
<input <input
type="text" type="text"
class="form-control" class="form-control form-control-sm"
v-model="displayValue" v-model="displayValue"
@input="onInput" @input="onInput"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
style="padding-right: 30px;" :style="{'padding-right': $slots.append ? '30px' : '0'}"
/> />
<slot name="append"></slot> <slot name="append"></slot>
@@ -20,9 +20,9 @@ Vue.component('tt-autocomplete', {
<div v-show="isLoading" class="loader"></div> <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 <li
v-for="(item, index) in items.slice(0, 10)" v-for="(item) in displayingItems.slice(0, 10)"
:key="item.value" :key="item.value"
:class="{'active': value === item.value}" :class="{'active': value === item.value}"
class="dropdown-item" class="dropdown-item"
@@ -32,15 +32,16 @@ Vue.component('tt-autocomplete', {
{{ item.text }} {{ item.text }}
</li> </li>
<!-- display more search results available define it more precisely in German --> <!-- 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 Mehr Suchergebnisse vorhanden. Bitte genauer eingeben
</li> </li>
</template> </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. Keine Suchergebnisse vorhanden.
</li> </li>
<li v-show="displayValue.length < 3" class="dropdown-item disabled"> <li v-show="displayValue.length < 3" class="dropdown-item disabled">
Bitte mindestens 3 Zeichen eingeben Bitte mindestens 3 Zeichen eingeben
</li> </li>
</ul> </ul>
</div> </div>
@@ -54,19 +55,29 @@ Vue.component('tt-autocomplete', {
}, },
label: { label: {
type: String, type: String,
required: true, required: false,
}, },
apiUrl: String, apiUrl: String,
items: {
type: Array,
default: () => [],
}
}, },
async mounted() { async mounted() {
if (this.value) { if (this.value && this.apiUrl) {
const response = await axios.get(`${this.apiUrl}&autocomplete=1&searchedID=${this.value}`); const response = await axios.get(`${this.apiUrl}&autocomplete=1&searchedID=${this.value}`);
this.displayValue = response.data[0].text; 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() { data() {
return { return {
items: [], displayingItems: [],
displayValue: '', displayValue: '',
isLoading: false, isLoading: false,
showSuggestions: false, showSuggestions: false,
@@ -76,7 +87,7 @@ Vue.component('tt-autocomplete', {
}, },
watch: { watch: {
value(newValue) { 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 : ''; this.displayValue = selectedItem ? selectedItem.text : '';
}, },
apiUrl() { apiUrl() {
@@ -86,6 +97,7 @@ Vue.component('tt-autocomplete', {
methods: { methods: {
onInput(event) { onInput(event) {
this.displayValue = event.target.value; this.displayValue = event.target.value;
this.$emit('input', undefined);
this.fetchSuggestions(); this.fetchSuggestions();
}, },
onFocus() { onFocus() {
@@ -97,7 +109,28 @@ Vue.component('tt-autocomplete', {
}, 200); }, 200);
}, },
fetchSuggestions() { 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; this.isLoading = true;
clearTimeout(this.fetchSuggestionsDebounceTimer); clearTimeout(this.fetchSuggestionsDebounceTimer);
@@ -107,9 +140,9 @@ Vue.component('tt-autocomplete', {
setTimeout(async () => { setTimeout(async () => {
const response = await axios.get(`${this.apiUrl}&autocomplete=1&q=${this.displayValue}`); const response = await axios.get(`${this.apiUrl}&autocomplete=1&q=${this.displayValue}`);
if (response.data?.status === 'error') { if (response.data?.status === 'error') {
this.items = []; this.displayingItems = [];
} else { } else {
this.items = response.data this.displayingItems = response.data
} }
this.isLoading = false; this.isLoading = false;

View File

@@ -8,10 +8,13 @@ Vue.component('tt-icon-select', {
}; };
}, },
mounted() { mounted() {
// Dynamically add CSS to disable default dropdown caret if (! document.getElementById('tt-icon-select-style')) {
const style = document.createElement('style'); // Dynamically add CSS to disable default dropdown caret
style.innerHTML = `.tt-select .dropdown-toggle::after { display: none; }`; const style = document.createElement('style');
document.body.appendChild(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); document.addEventListener('click', this.handleClick);

View File

@@ -44,9 +44,11 @@ Vue.component('tt-number-range', {
} }
} }
}, template: ` }, 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> <slot name="prepend"></slot>
<input type="number" <input type="number"
style="-webkit-appearance: none;
-moz-appearance: textfield;"
class="form-control form-control-sm" class="form-control form-control-sm"
v-model.number="inputValueFrom" v-model.number="inputValueFrom"
@input="updateValue" @input="updateValue"
@@ -60,3 +62,5 @@ Vue.component('tt-number-range', {
</div> </div>
` `
}); });

View File

@@ -1,22 +1,44 @@
/** /**
* @typedef {Object} ttTableColumnConfig * @typedef {Object} ttTableColumnConfig
* @property {string} text - The display text of the column. * @property {string} text - The display text of the column header.
* @property {string} key - The unique key of the column. * @property {string} key - The unique key of the column (used for data access and filtering).
* @property {string} filter - Indicates if filtering is enabled for the column. * @property {string} [filter] - (Optional) Determines the type of filter applied to the column. Default is 'search'.
* @property {boolean} sortable - Indicates if sorting is enabled for the column. * Possible values:
* @property {string} class - The CSS class(es) applied to the column. * - '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', { Vue.component('tt-table-pagination', {
props: { props: {
pagination: { pagination: {
type: Object, type: Object,
required: true, required: true,
default: () => ({page: 1, per_page: 10, total_rows: 0, filtered_available: 0, total_pages: 1}) default: () => ({page: 1, per_page: 10, total_rows: 0, filtered_available: 0, total_pages: 1})
}, }, reverse: {type: Boolean, default: false}
reverse: {type: Boolean, default: false} }, computed: {
},
computed: {
pagesToDisplay() { pagesToDisplay() {
const range = 2; const range = 2;
const start = Math.max(this.pagination.page - range, 1); const start = Math.max(this.pagination.page - range, 1);
@@ -26,65 +48,61 @@ Vue.component('tt-table-pagination', {
pages.push(i); pages.push(i);
} }
return pages.length === 0 ? [1] : pages; 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 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 end = Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total_rows);
const total = this.pagination.total_rows === this.pagination.filtered_available const total = this.pagination.total_rows === this.pagination.filtered_available ? this.pagination.total_rows : `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
? this.pagination.total_rows
: `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
return `${start} bis ${end} von ${total}`; return `${start} bis ${end} von ${total}`;
} }
}, }, methods: {
methods: {
fetchRows(page) { fetchRows(page) {
this.$emit('fetch-rows', page); this.$emit('fetch-rows', page);
} }
}, }, template: `
template: ` <div class="tt-table-pagination-container">
<div class="tt-table-pagination-container"> <div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
<span class="tt-table-text-center" v-text="pageInfoText" <span class="tt-table-text-center" v-text="pageInfoText"
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 1 }"></span> :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 }"> <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 }"> <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"> <a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
<span class="sr-only">First</span> <span class="sr-only">First</span>
</a> </a>
</li> </li>
<li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay" <li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay"
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"> v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }">
<a class="page-link" <a class="page-link"
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }" v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"
href="#" href="#"
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a> v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
</li> </li>
<li class="page-item tt-table-page-item" <li class="page-item tt-table-page-item"
v-bind:class="{ disabled: pagination.page === pagination.total_pages }"> v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)" <a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
aria-label="Last"> aria-label="Last">
<span aria-hidden="true">&raquo;</span> <span aria-hidden="true">&raquo;</span>
<span class="sr-only">Last</span> <span class="sr-only">Last</span>
</a> </a>
</li> </li>
</ul> </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)" <select v-model="pagination.per_page" v-on:change="fetchRows(1)"
class="form-control form-control-sm tt-table-select" class="form-control form-control-sm tt-table-select"
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }"> :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
</select> </select>
</div>
</div> </div>
</div>
` `
}) })
@@ -92,7 +110,7 @@ Vue.component('tt-table-pagination', {
Vue.component('tt-table', { Vue.component('tt-table', {
template: ` template: `
<div class="card tt-table-card" v-if="columns && pagination"> <div class="card tt-table-card" v-if="columns && pagination">
<div class="card-body"> <div class="card-body" ref="tableContainer">
<!-- Top Buttons --> <!-- Top Buttons -->
<div <div
style="display:grid; grid-template-columns: auto auto auto auto auto; grid-gap: 8px; padding-bottom: 8px"> 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> </div>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<div <div class="tt-table-top-pagination-container">
style="display:grid; grid-template-columns: 1fr 1fr;padding-bottom: 8px;align-items:center; justify-content: space-between">
<!-- if excelExport is true, show the export button fontawesome icon excel --> <!-- if excelExport is true, show the export button fontawesome icon excel -->
<div style="display:flex;align-items: center;"> <div style="display:flex;align-items: center;">
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen" <i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen"
@click="resetTable" class="fa-solid fa-trash-undo" @click="resetTable" class="fas fa-times cursor-pointer text-danger"
style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i> style="font-size: 24px;margin-right: 6px;cursor: pointer; color: var(--orange)"></i>
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel" <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>
<div v-if="pagination && typeof pagination.total_rows === 'number'" <tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end"> v-if="pagination"></tt-table-pagination>
<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">&laquo;</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">&raquo;</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>
</div> </div>
</nav> </nav>
<!-- Table --> <!-- Table -->
<table <table
ref="table"
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]"> :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"> <thead style="border-width: 2px">
<tr> <tr>
<th scope="col" v-for="column in columns" <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;' : '')"> :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;" <div style="text-align:center; white-space: nowrap;word-break: keep-all;"
:style="{ 'cursor': column.sortable ? 'pointer' : 'default' }" :style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
@@ -169,6 +159,10 @@ Vue.component('tt-table', {
<tt-select v-else-if="column.filter === 'select'" <tt-select v-else-if="column.filter === 'select'"
:options="[{text: 'Alle', value: undefined}, ...column.filterOptions]" :options="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
v-model="filters[column.key]"></tt-select> 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> <tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"></tt-date-picker>
</th> </th>
</tr> </tr>
@@ -181,16 +175,18 @@ Vue.component('tt-table', {
style="height: 150px"> style="height: 150px">
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td> <td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
</tr> </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) : ''" <tr :class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''"
@click="$emit('row-click', 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 }"> <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 --> <!-- 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))" <i v-if="key === Object.keys(columns)[0] &&
@click.stop="toggleExpand(row.id)" ($scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row)) || hiddenColumns.length > 0)"
:class="isExpanded(row.id) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"
@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> style="cursor: pointer;font-size: 14px;padding-right: 8px;user-select: none"></i>
<slot :name="key.toLowerCase()" :value="row[key]" :row="row"> <slot :name="key.toLowerCase()" :value="row[key]" :row="row">
<span <span
@@ -199,13 +195,35 @@ Vue.component('tt-table', {
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text" :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> :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
<span v-else <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> </slot>
</td> </td>
</template> </template>
</tr> </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"> <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> <slot name="expandedRow" :row="row"></slot>
</td> </td>
</tr> </tr>
@@ -234,7 +252,6 @@ Vue.component('tt-table', {
return { return {
window: window, window: window,
moment: window.moment, moment: window.moment,
XLSX: window.XLSX,
loading: false, loading: false,
rows: null, rows: null,
rawRows: null, rawRows: null,
@@ -244,11 +261,13 @@ Vue.component('tt-table', {
disableDebounce: false, disableDebounce: false,
latestFetchTimestamp: null, latestFetchTimestamp: null,
order: { order: {
key: null, key: null, order: 'asc' // default sort order
order: 'asc' // default sort order
}, },
expandedRows: {}, expandedRows: {},
isInitialised: false isInitialised: false,
hiddenColumns: [],
originalColumnWidths: {},
originalTableWidth: null
}; };
}, },
@@ -274,6 +293,8 @@ Vue.component('tt-table', {
* @async * @async
*/ */
async fetchData(page = 0) { async fetchData(page = 0) {
this.expandedRows = {};
try { try {
if (this.ssr === false) { if (this.ssr === false) {
const response = await axios.get(this.fetchUrl); const response = await axios.get(this.fetchUrl);
@@ -332,8 +353,7 @@ Vue.component('tt-table', {
} else { } else {
await this.fetchData(page); // Directly call fetchData without debounce await this.fetchData(page); // Directly call fetchData without debounce
} }
}, }, saveSettingsToLocalStorage() {
saveSettingsToLocalStorage() {
if (this.isInitialised === false) return; if (this.isInitialised === false) return;
const filters = Object.entries(this.filters).reduce((acc, [key, value]) => { const filters = Object.entries(this.filters).reduce((acc, [key, value]) => {
@@ -353,21 +373,17 @@ Vue.component('tt-table', {
filters, filters,
paginationPerPage: this.pagination.per_page, paginationPerPage: this.pagination.per_page,
order: this.order.key ? this.order : undefined, order: this.order.key ? this.order : undefined,
expandedRows: this.expandedRows
})); }));
}, }, parseSettingsFromLocalStorage() {
parseSettingsFromLocalStorage() {
const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}'); const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}');
if (settings) { if (settings) {
this.disableDebounce = true; this.disableDebounce = true;
this.filters = settings.filters || {}; this.filters = settings.filters || {};
this.pagination.per_page = parseInt(settings.paginationPerPage) || this.config.defaultPageSize || 10; this.pagination.per_page = parseInt(settings.paginationPerPage) || this.config.defaultPageSize || 10;
this.order = settings.order || {key: null, order: 'asc'}; this.order = settings.order || {key: null, order: 'asc'};
this.expandedRows = settings.expandedRows || {};
} }
return !!settings; return !!settings;
}, }, setOrder(key) {
setOrder(key) {
if (this.order.key === key) { if (this.order.key === key) {
// if current order is desc then set key to null // if current order is desc then set key to null
if (this.order.order === 'desc') { if (this.order.order === 'desc') {
@@ -382,16 +398,12 @@ Vue.component('tt-table', {
this.order.key = key; this.order.key = key;
this.order.order = 'asc'; this.order.order = 'asc';
} }
}, }, getSortIconClass(key) {
getSortIconClass(key) {
if (this.order.key === key) { if (this.order.key === key) {
return this.order.order === 'asc' ? 'fa fa-sort-asc' : 'fa fa-sort-desc'; return this.order.order === 'asc' ? 'fa fa-sort-asc' : 'fa fa-sort-desc';
} }
return 'fa fa-sort'; // default icon when not sorted return 'fa fa-sort'; // default icon when not sorted
}, }, async exportToExcel() {
async exportToExcel() {
// create script and await downloading: /plugins/xlsx/xlsx.min.js
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement('script');
script.src = '/plugins/xlsx/xlsx.min.js'; script.src = '/plugins/xlsx/xlsx.min.js';
@@ -400,29 +412,41 @@ Vue.component('tt-table', {
document.head.appendChild(script); 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 parsedRow;
return row; });
}); }
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) { for (const cell in ws) {
if (cell.startsWith('!')) continue; // Skip non-cell properties like '!ref' if (cell.startsWith('!')) continue; // Skip non-cell properties like '!ref'
const cellValue = ws[cell].v; const cellValue = ws[cell].v;
if (cellValue.toString().includes('\n')) { 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 = {}; if (!ws[cell].s) ws[cell].s = {};
ws[cell].s.alignment = {wrapText: true, vertical: 'center'}; ws[cell].s.alignment = {wrapText: true, vertical: 'center'};
} else { } else {
@@ -430,26 +454,54 @@ Vue.component('tt-table', {
} }
} }
this.XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); this.window.XLSX.utils.book_append_sheet(wb, ws, "Export");
this.XLSX.writeFile(wb, 'export.xlsx'); this.window.XLSX.writeFile(wb, 'export.xlsx');
}, }, resetTable() {
resetTable() {
this.$emit('reset-table'); this.$emit('reset-table');
this.filters = {}; this.filters = {};
this.order = {key: null, order: 'asc'}; this.order = {key: null, order: 'asc'};
this.expandedRows = {}; this.expandedRows = {};
this.disableDebounce = true; this.disableDebounce = true;
window.notify('success', 'Filter zurückgesetzt'); window.notify('success', 'Filter zurückgesetzt');
}, }, toggleExpand(index) {
toggleExpand(index) {
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true); this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true);
}, }, isExpanded(index) {
isExpanded(index) {
return !!this.expandedRows[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: { }, watch: {
filters: { filters: {
handler: function (newVal, oldVal) { handler: function () {
if (!this.isInitialised) return; if (!this.isInitialised) return;
if (this.ssr) { if (this.ssr) {
@@ -457,17 +509,15 @@ Vue.component('tt-table', {
} }
this.saveSettingsToLocalStorage(); this.saveSettingsToLocalStorage();
}, deep: true }, deep: true
}, }, 'pagination.per_page': {
'pagination.per_page': {
handler: function (newVal, oldVal) { handler: function (newVal, oldVal) {
if (!this.isInitialised) return; if (!this.isInitialised) return;
if (newVal === oldVal) return if (newVal === oldVal) return
this.saveSettingsToLocalStorage(); this.saveSettingsToLocalStorage();
}, deep: true }, deep: true
}, }, order: {
order: { handler: function () {
handler: function (newVal, oldVal) {
if (!this.isInitialised) return; if (!this.isInitialised) return;
if (this.ssr) { if (this.ssr) {
@@ -475,13 +525,26 @@ Vue.component('tt-table', {
} }
this.saveSettingsToLocalStorage(); this.saveSettingsToLocalStorage();
}, deep: true }, deep: true
}, }, expandedRows: {
expandedRows: { handler: function () {
handler: function (newVal, oldVal) {
if (!this.isInitialised) return; if (!this.isInitialised) return;
this.saveSettingsToLocalStorage(); this.saveSettingsToLocalStorage();
}, deep: true }, deep: true
},
rows: {
handler: async function () {
this.$nextTick(() => {
this.handleResponsiveColumns()
})
}, deep: true
},
computedRows: {
handler: async function () {
this.$nextTick(() => {
this.handleResponsiveColumns()
})
}, deep: true
} }
}, computed: { }, computed: {
/** /**
@@ -489,18 +552,23 @@ Vue.component('tt-table', {
* @return {ttTableColumnConfig} The columns configuration. * @return {ttTableColumnConfig} The columns configuration.
*/ */
columns() { columns() {
let i = this.config.headers.length;
return this.config.headers.reduce((columns, column) => { return this.config.headers.reduce((columns, column) => {
if (!column.key) { 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 return columns; // Continue to the next iteration without modifying the accumulator
} }
columns[column.key] = { columns[column.key] = {
text: column.text, text: column.text,
key: column.key, key: column.key,
filter: column.filter !== undefined ? column.filter : 'search', filter: column.filter !== undefined ? column.filter : 'search',
filterOptions: column.filterOptions || undefined, filterOptions: column.filterOptions || undefined,
sortable: column.sortable !== undefined ? column.sortable : true, 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; return columns;
}, {}); }, {});
@@ -526,10 +594,9 @@ Vue.component('tt-table', {
} }
return pagesArray.length === 0 ? [1] : pagesArray; return pagesArray.length === 0 ? [1] : pagesArray;
}, }, computedRows() {
computedRows() {
if (!this.rawRows || this.ssr === true) return null; if (!this.rawRows || this.ssr === true) return null;
console.time('Filtering and pagination'); // console.time('Filtering and pagination');
function handleRangeFilter(filter, value) { function handleRangeFilter(filter, value) {
if (filter[0] === '<') { if (filter[0] === '<') {
@@ -567,6 +634,7 @@ Vue.component('tt-table', {
const header = headers[filter]; const header = headers[filter];
const filterValue = filters[filter]; const filterValue = filters[filter];
if (!filterValue) continue;
if (filterValue === '') continue; if (filterValue === '') continue;
if (filterValue === "!") continue; if (filterValue === "!") continue;
@@ -596,7 +664,7 @@ Vue.component('tt-table', {
match = false; match = false;
break; 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 === '') continue;
if (filterValue !== row[header.key].toString()) { if (filterValue !== row[header.key].toString()) {
match = false; match = false;
@@ -644,7 +712,7 @@ Vue.component('tt-table', {
} }
console.timeEnd('Filtering and pagination'); // console.timeEnd('Filtering and pagination');
// Pagination and slice logic // Pagination and slice logic
this.pagination.total_pages = Math.ceil(output.length / this.pagination.per_page); this.pagination.total_pages = Math.ceil(output.length / this.pagination.per_page);
this.pagination.filtered_available = output.length; this.pagination.filtered_available = output.length;
@@ -656,14 +724,12 @@ Vue.component('tt-table', {
return this.rows; return this.rows;
} }
}, }, async created() {
async created() {
if (this.config.hasOwnProperty('defaultPageSize') && this.config.defaultPageSize) { if (this.config.hasOwnProperty('defaultPageSize') && this.config.defaultPageSize) {
this.pagination = {page: 1, per_page: this.config.defaultPageSize, total_rows: null, total_pages: 1}; this.pagination = {page: 1, per_page: this.config.defaultPageSize, total_rows: null, total_pages: 1};
} }
this.parseSettingsFromLocalStorage() this.parseSettingsFromLocalStorage()
if (!this.disableInitialFetch) { if (!this.disableInitialFetch) {
@@ -675,8 +741,24 @@ Vue.component('tt-table', {
// if sticky is true then add style element to style thead sticky // if sticky is true then add style element to style thead sticky
if (this.sticky) { if (this.sticky) {
const style = document.createElement('style'); 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); document.head.appendChild(style);
} }
window.addEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
}, },
beforeDestroy() {
window.removeEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
}
}) })