893 lines
45 KiB
JavaScript
893 lines
45 KiB
JavaScript
/**
|
|
* @typedef {Object} ttTableColumnConfig
|
|
* @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} [headerClass] - (Optional) Additional CSS classes to apply to the column header.
|
|
* @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: {
|
|
pagesToDisplay() {
|
|
const range = 2;
|
|
const start = Math.max(this.pagination.page - range, 1);
|
|
const end = Math.min(this.pagination.page + range, this.pagination.total_pages);
|
|
let pages = [];
|
|
for (let i = start; i <= end; i++) {
|
|
pages.push(i);
|
|
}
|
|
return pages.length === 0 ? [1] : pages;
|
|
}, pageInfoText() {
|
|
let start = Math.max(this.pagination.filtered_available, 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.filtered_available);
|
|
if (start > end) start = end;
|
|
|
|
if (!this.pagination.filtered_available) 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: {
|
|
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">
|
|
|
|
<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 || pagination.total_pages <= 1 }">
|
|
<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>
|
|
|
|
|
|
<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>
|
|
`
|
|
})
|
|
|
|
|
|
Vue.component('tt-table', {
|
|
template: `
|
|
<div class="tt-table-container" ref="tableContainer">
|
|
<!-- Top Buttons -->
|
|
<div class="tt-table-top-buttons-container">
|
|
<slot name="top-buttons"></slot>
|
|
</div>
|
|
<!-- Pagination Controls -->
|
|
<nav aria-label="Page navigation">
|
|
<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;">
|
|
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
|
|
</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 },
|
|
config.key + '-table']">
|
|
<thead style="border-width: 2px">
|
|
<tr>
|
|
<th scope="col" v-for="column in columns"
|
|
:ref="'table_header_'+column.key"
|
|
:class="column.headerClass ? column.headerClass : 'tt-table-header'"
|
|
v-if="!hiddenColumns.includes(column.key)"
|
|
:style="(column.filter === 'dateRange' ? 'min-width: 260px;' : '') +
|
|
(originalColumnWidths[column.key] ? 'width: ' + originalColumnWidths[column.key] + 'px;' : '')"
|
|
>
|
|
<div
|
|
style="white-space: nowrap;word-break: keep-all;user-select: none;"
|
|
:style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
|
|
@click="column.sortable && !disableFiltering ? setOrder(column.key) : undefined">
|
|
{{ column.text }}
|
|
<i v-if="column.sortable && !disableFiltering"
|
|
:class="getSortIconClass(column.key)"></i>
|
|
</div>
|
|
<!-- @formatter:off -->
|
|
<tt-input v-if="column.filter === 'search' && !disableFiltering" v-model="filters[column.key]" sm/>
|
|
<tt-icon-select v-else-if="column.filter === 'iconSelect' && !disableFiltering" :options="column.filterOptions" v-model="filters[column.key]" sm :multiple="column.filterOptions.length > 2 && this.ssr === true" sm/>
|
|
<tt-number-range v-else-if="column.filter === 'numberRange' && !disableFiltering" :returnText="!ssr" v-model="filters[column.key]" sm/>
|
|
<tt-select v-else-if="column.filter === 'select' && !disableFiltering" :options="[...column.filterOptions]" v-model="filters[column.key]" sm multiple/>
|
|
<tt-autocomplete v-else-if="column.filter === 'autocomplete' && !disableFiltering" :api-url="column.filterOptions" v-model="filters[column.key]" sm/>
|
|
<tt-date-picker v-else-if="(column.filter === 'date' || column.filter === 'datepicker') && !disableFiltering" v-model="filters[column.key]" sm/>
|
|
<!-- @formatter:on -->
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="pagination?.filtered_available === 0" style="height: 150px">
|
|
<td :colspan="Object.keys(columns).length" :rowspan="5" class="text-center">Keine Ergebnisse!</td>
|
|
</tr>
|
|
<tr v-else-if="(pagination === null && ssr === true) || rows === null"
|
|
style="height: 150px">
|
|
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
|
|
</tr>
|
|
<template v-for="(row, index) in (ssr === false ? visibleRows : rows)">
|
|
<tr :class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''"
|
|
@click="$emit('row-click', row)"
|
|
>
|
|
<template v-for="(column, key) in columns" v-if="!hiddenColumns.includes(column.key)">
|
|
<td :class="{ 'text-center': column.filter === 'iconSelect',
|
|
[columns[key].class]: true,
|
|
//'text-nowrap': !originalColumnWidths[column.key]
|
|
}"
|
|
>
|
|
<!-- 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)) || 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
|
|
v-if="(column.filter === 'date' || column.filter === 'datepicker')">{{
|
|
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-if="column.filter === 'autocomplete'">
|
|
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}
|
|
|
|
|
|
</span>
|
|
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value?.toString() === row[key]?.toString())?.text }}</span>
|
|
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
|
|
<span v-else-if="row[key] !== null"
|
|
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(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' || column.filter === 'datepicker')">{{
|
|
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>
|
|
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }}</span>
|
|
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</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-if="column.filter === 'autocomplete'">
|
|
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}</span>
|
|
|
|
<span v-else-if="row[key] !== null"
|
|
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>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<!-- Pagination Controls -->
|
|
<nav aria-label="Page navigation">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<button v-if="!Object.values(columns).every(column => column.filter === false) || disableFiltering"
|
|
@click="resetTable" class="btn btn-outline-secondary mr-2"
|
|
>
|
|
<i class="fas fa-filter"></i>
|
|
Filter zurücksetzen
|
|
</button>
|
|
<button v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="btn btn-outline-success">
|
|
<i class="fa fa-file-excel" style="color: var(--success)"></i>
|
|
Excel Export
|
|
</button>
|
|
</div>
|
|
|
|
<tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
|
|
v-if="pagination"></tt-table-pagination>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
`, props: {
|
|
fetchUrl: String,
|
|
data: Array,
|
|
striped: {type: Boolean, default: true},
|
|
bordered: {type: Boolean, default: true},
|
|
hover: {type: Boolean, default: true},
|
|
sticky: {type: Boolean, default: true},
|
|
small: {type: Boolean, default: true},
|
|
excelExport: {type: Boolean, default: false},
|
|
config: {type: Object, default: () => ({}), required: true},
|
|
ssr: {type: Boolean, default: false},
|
|
disableInitialFetch: {type: Boolean, default: false},
|
|
disableFiltering: {type: Boolean, default: false}
|
|
}, data() {
|
|
return {
|
|
window: window,
|
|
moment: window.moment,
|
|
loading: false,
|
|
rows: null,
|
|
rawRows: null,
|
|
pagination: {},
|
|
filters: {},
|
|
debounceTimeout: null,
|
|
disableDebounce: false,
|
|
latestFetchTimestamp: null,
|
|
order: {
|
|
key: null, order: 'asc' // default sort order
|
|
},
|
|
expandedRows: {},
|
|
isInitialised: false,
|
|
hiddenColumns: [],
|
|
originalColumnWidths: {},
|
|
originalTableWidth: null,
|
|
debouncedHandleResize: null,
|
|
autoCompleteData: {}
|
|
};
|
|
},
|
|
|
|
methods: {
|
|
/**
|
|
* Creates a debounced function that delays invoking `fn` until after `wait` milliseconds
|
|
* have elapsed since the last time the debounced function was invoked.
|
|
*
|
|
* @param {Function} fn The function to debounce.
|
|
* @param {number} wait The number of milliseconds to delay.
|
|
* @return {Function} The debounced function.
|
|
*/
|
|
debounce(fn, wait) {
|
|
return function (...args) {
|
|
const context = this;
|
|
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
|
|
this.debounceTimeout = setTimeout(() => fn.apply(context, args), wait);
|
|
}
|
|
}, refreshTable() {
|
|
this.loading = true;
|
|
this.fetchData(this.pagination.page).then()
|
|
}, /**
|
|
* Fetches and updates data for a specified page.
|
|
*
|
|
* @param {number} page The page number to fetch data for.
|
|
* @async
|
|
*/
|
|
async fetchData(page = 0) {
|
|
this.expandedRows = {};
|
|
|
|
try {
|
|
if (this.ssr === false) {
|
|
if (this.data) {
|
|
this.rawRows = this.data;
|
|
} else {
|
|
const response = await axios.get(this.fetchUrl);
|
|
this.rawRows = response.data
|
|
}
|
|
|
|
this.pagination = {
|
|
page: Math.max(page, 1),
|
|
per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10,
|
|
total_rows: this.rawRows.length || 0,
|
|
total_pages: this.rawRows.length / this.pagination?.per_page,
|
|
filtered_available: this.rawRows.length
|
|
};
|
|
this.loading = false;
|
|
return
|
|
}
|
|
|
|
const fetchTimestamp = Date.now();
|
|
this.latestFetchTimestamp = fetchTimestamp;
|
|
console.log(this.fetchUrl);
|
|
const response = await axios.post(this.fetchUrl, {
|
|
pagination: {
|
|
page: Math.max(page, 1), per_page: this.pagination?.per_page ? this.pagination.per_page : 10,
|
|
}, filters: this.filters, order: this.order
|
|
}).catch(error => {
|
|
if (this.window.fetchErrorOccurred === undefined) {
|
|
this.filters = {};
|
|
this.order = {key: null, order: 'asc'};
|
|
this.expandedRows = {};
|
|
this.disableDebounce = true;
|
|
this.fetchData(page).then();
|
|
}
|
|
this.window.fetchErrorOccurred = true;
|
|
})
|
|
|
|
if (fetchTimestamp !== this.latestFetchTimestamp) return;
|
|
|
|
if (typeof response.data !== 'object') { // if the response is not an object
|
|
this.rows = [];
|
|
this.pagination = {page: 1, per_page: 10, total_rows: 0, total_pages: 1};
|
|
} else {
|
|
this.rows = response.data.rows;
|
|
this.autoCompleteData = response.data.autoCompleteData;
|
|
this.pagination = response.data.pagination;
|
|
}
|
|
this.loading = false;
|
|
this.isInitialised = true;
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
}
|
|
}, /**
|
|
* Fetches rows for a given page, with an option to debounce the fetch operation.
|
|
*
|
|
* @param {number} page The page number to fetch. Defaults to 1.
|
|
* @param {boolean} debounce Whether to debounce the fetch operation. Defaults to false.
|
|
*/
|
|
async fetchRows(page = 1, debounce = false) {
|
|
|
|
if (this.disableDebounce === true) {
|
|
debounce = false;
|
|
this.disableDebounce = false;
|
|
}
|
|
|
|
this.loading = true
|
|
if (debounce) {
|
|
this.debounce(this.fetchData.bind(this), 300)(page);
|
|
} else {
|
|
await this.fetchData(page); // Directly call fetchData without debounce
|
|
}
|
|
}, saveSettingsToLocalStorage() {
|
|
if (this.isInitialised === false) return;
|
|
if (this.disableFiltering) return;
|
|
|
|
const filters = Object.entries(this.filters).reduce((acc, [key, value]) => {
|
|
if (!value && value !== 0) {
|
|
return acc; // Skip empty strings
|
|
}
|
|
value = JSON.parse(JSON.stringify(value)); // Deep copy to avoid Vue reactivity
|
|
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
|
|
return acc; // Skip empty objects
|
|
}
|
|
acc[key] = value; // Add non-empty properties to accumulator
|
|
return acc;
|
|
}, {});
|
|
|
|
localStorage.setItem(`tt-table-${this.config.key}`, JSON.stringify({
|
|
// filter filters with empty values or empty objects
|
|
filters, paginationPerPage: this.pagination.per_page, order: this.order.key ? this.order : undefined,
|
|
}));
|
|
}, parseSettingsFromLocalStorage() {
|
|
if (this.disableFiltering) return false;
|
|
|
|
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'};
|
|
}
|
|
return !!settings;
|
|
}, setOrder(key) {
|
|
if (this.order.key === key) {
|
|
// if current order is desc then set key to null
|
|
if (this.order.order === 'desc') {
|
|
this.order.key = null;
|
|
this.order.order = 'asc';
|
|
} else {
|
|
// if current order is asc then set order to desc
|
|
this.order.order = 'desc';
|
|
}
|
|
} else {
|
|
// Set new key and default to ascending
|
|
this.order.key = key;
|
|
this.order.order = 'asc';
|
|
}
|
|
}, 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() {
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = '/plugins/xlsx/xlsx.min.js';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
})
|
|
|
|
function defaultExcelProcessor(rows) {
|
|
return rows.map(row => {
|
|
const parsedRow = {};
|
|
|
|
for (const key in row) {
|
|
if (!this.columns[key]) continue;
|
|
|
|
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 {
|
|
parsedRow[this.columns[key].text] = row[key];
|
|
}
|
|
|
|
}
|
|
return parsedRow;
|
|
});
|
|
}
|
|
|
|
const wb = this.window.XLSX.utils.book_new();
|
|
|
|
|
|
let data = typeof this.config.customExcelProcessor ===
|
|
'function' ? this.config.customExcelProcessor(JSON.parse(JSON.stringify(this.computedRows))) : defaultExcelProcessor.call(this,
|
|
JSON.parse(JSON.stringify(this.computedRows)));
|
|
|
|
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);
|
|
if (!ws[cell].s) ws[cell].s = {};
|
|
ws[cell].s.alignment = {wrapText: true, vertical: 'center'};
|
|
} else {
|
|
ws[cell].s = {alignment: {vertical: 'center'}};
|
|
}
|
|
}
|
|
|
|
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) {
|
|
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true);
|
|
}, isExpanded(index) {
|
|
return !!this.expandedRows[index];
|
|
}, async handleResponsiveColumns() {
|
|
if (!this.$refs.tableContainer) return;
|
|
const tableContainer = this.$refs.tableContainer;
|
|
const {paddingLeft, paddingRight} = window.getComputedStyle(tableContainer);
|
|
|
|
let viewportWidth = tableContainer.offsetWidth - parseInt(paddingLeft) - parseInt(paddingRight);
|
|
this.originalColumnWidths = {}
|
|
this.hiddenColumns = []
|
|
await this.$nextTick()
|
|
|
|
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 + 10 < 0) {
|
|
this.hiddenColumns.push(columns[i].key)
|
|
}
|
|
}
|
|
}
|
|
|
|
}, watch: {
|
|
data: {
|
|
handler: function () {
|
|
// we need to refresh the table if the prop data changes
|
|
this.loading = true;
|
|
this.rawRows = this.data;
|
|
this.pagination = {
|
|
page: Math.max(this.pagination.page, 1),
|
|
per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10,
|
|
total_rows: this.rawRows.length || 0,
|
|
total_pages: this.rawRows.length / this.pagination?.per_page,
|
|
filtered_available: this.rawRows.length
|
|
}
|
|
this.loading = false;
|
|
this.isInitialised = true;
|
|
this.$nextTick(() => {
|
|
this.handleResponsiveColumns()
|
|
})
|
|
}
|
|
},
|
|
filters: {
|
|
handler: function () {
|
|
if (!this.isInitialised) return;
|
|
|
|
// go through filters and if there is a set value in filters and the filter of the column is select or autocomplete then parse the value to int
|
|
for (const key in this.filters) {
|
|
if (!this.columns[key]) continue;
|
|
|
|
if (this.filters[key] && (this.columns[key].filter === 'select' || this.columns[key].filter === 'autocomplete')) {
|
|
// only if first character is a number
|
|
// if (!isNaN(this.filters[key][0])) {
|
|
// this.filters[key] = parseInt(this.filters[key]);
|
|
// }
|
|
}
|
|
}
|
|
|
|
if (this.ssr) {
|
|
this.fetchRows(this.pagination?.page || 1, true).then();
|
|
}
|
|
this.saveSettingsToLocalStorage();
|
|
}, deep: true
|
|
}, 'pagination.per_page': {
|
|
handler: function (newVal, oldVal) {
|
|
if (!this.isInitialised) return;
|
|
if (newVal === oldVal) return
|
|
|
|
this.saveSettingsToLocalStorage();
|
|
}, deep: true
|
|
}, order: {
|
|
handler: function () {
|
|
if (!this.isInitialised) return;
|
|
|
|
if (this.ssr) {
|
|
this.fetchRows(this.pagination?.page || 1, true).then();
|
|
}
|
|
this.saveSettingsToLocalStorage();
|
|
}, deep: true
|
|
}, 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: {
|
|
/**
|
|
* Returns an object containing the columns' configuration.
|
|
* @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);
|
|
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 : '',
|
|
headerClass: column.headerClass || false,
|
|
prefix: column.prefix || '',
|
|
suffix: column.suffix || '',
|
|
priority: column.priority + 100 || i--
|
|
};
|
|
return columns;
|
|
}, {});
|
|
|
|
}, pagesToDisplay() {
|
|
let range = 2; // Number of pages before and after the current page
|
|
let start = (this.pagination.page < 4 ? 1 : this.pagination.page - range);
|
|
let end = (this.pagination.page + range > this.pagination.total_pages ? this.pagination.total_pages : this.pagination.page + range);
|
|
if (end < 5) end = 5;
|
|
|
|
// Adjust start and end if they are out of bounds
|
|
end = end > this.pagination.total_pages ? this.pagination.total_pages : end;
|
|
|
|
// Adjust the start and end if we are at the end of the page range
|
|
if (this.pagination.page > this.pagination.total_pages - 2) {
|
|
start = this.pagination.total_pages - 4 < 1 ? 1 : this.pagination.total_pages - 4;
|
|
}
|
|
|
|
// Create an array of page numbers to display
|
|
let pagesArray = [];
|
|
for (let i = start; i <= end; i++) {
|
|
pagesArray.push(i);
|
|
}
|
|
|
|
return pagesArray.length === 0 ? [1] : pagesArray;
|
|
}, computedRows() {
|
|
if (!this.rawRows || this.ssr === true) return null;
|
|
|
|
// console.time('Filtering and pagination');
|
|
|
|
function handleRangeFilter(filter, value) {
|
|
if (filter[0] === '<') {
|
|
const bound = parseFloat(filter.slice(1));
|
|
return value <= bound;
|
|
} else if (filter[0] === '>') {
|
|
const bound = parseFloat(filter.slice(1));
|
|
return value >= bound;
|
|
} else if (filter.includes('-')) {
|
|
const bounds = filter.split('-').map(Number);
|
|
return value >= bounds[0] && value <= bounds[1];
|
|
}
|
|
return false; // Fallback for any non-matching cases
|
|
}
|
|
|
|
|
|
const data = this.rawRows;
|
|
const output = [];
|
|
const filters = this.filters;
|
|
const filtersLength = Object.keys(filters).length;
|
|
const headers = this.columns;
|
|
const dataLength = data.length;
|
|
|
|
for (var i = 0; i < dataLength; ++i) {
|
|
const row = data[i];
|
|
if (Object.keys(filters).length === 0) {
|
|
output.push(row);
|
|
continue;
|
|
}
|
|
|
|
let match = true;
|
|
for (var j = 0; j < filtersLength; ++j) {
|
|
// find header by filter key
|
|
const filter = Object.keys(filters)[j];
|
|
const header = headers[filter];
|
|
|
|
const filterValue = filters[filter];
|
|
if (!filterValue) continue;
|
|
if (!header) continue;
|
|
if (filterValue === '') continue;
|
|
if (filterValue === "!") continue;
|
|
|
|
if (header.filter === 'search') {
|
|
|
|
const isNegated = filterValue.startsWith('!');
|
|
const substrings = (isNegated ? filterValue.slice(1) : filterValue).split(' ')
|
|
.map(s => s.toLowerCase());
|
|
|
|
const targetValue = !row[header.key] ? '' : typeof row[header.key] === 'object' ? Object.values(row[header.key])
|
|
.join(' ')
|
|
.toLowerCase() : row[header.key].toString().toLowerCase();
|
|
|
|
let substringMatch = true;
|
|
for (var k = 0, klen = substrings.length; k < klen; ++k) {
|
|
|
|
if (!targetValue.includes(substrings[k])) {
|
|
substringMatch = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ((isNegated && substringMatch) || (!isNegated && !substringMatch)) {
|
|
match = false;
|
|
break;
|
|
}
|
|
|
|
|
|
} else if (header.filter === 'numberRange') {
|
|
const rangeMatch = handleRangeFilter(filterValue, parseFloat(row[header.key]));
|
|
if (!rangeMatch) {
|
|
match = false;
|
|
break;
|
|
}
|
|
} else if (header.filter === 'select' || header.filter === 'iconSelect' || header.filter === 'autocomplete') {
|
|
if (filterValue === '') continue;
|
|
if (Array.isArray(filterValue)) {
|
|
if (filterValue.length === 0) continue;
|
|
if (!filterValue.includes(row[header.key]?.toString())) {
|
|
match = false;
|
|
break;
|
|
}
|
|
} else if (filterValue !== row[header.key]?.toString()) {
|
|
match = false;
|
|
break;
|
|
}
|
|
} else if (header.filter === 'date') {
|
|
if (!filterValue.from || !filterValue.to) continue;
|
|
if (!row[header.key]) {
|
|
match = false;
|
|
break;
|
|
}
|
|
console.log(row);
|
|
|
|
const dateInt = row[header.key].length === 10 ? parseInt(row[header.key]) * 1000 : parseInt(row[header.key]);
|
|
let rowDate = new Date(dateInt).getTime() / 1000;
|
|
|
|
if (rowDate < filterValue.from || rowDate > filterValue.to) {
|
|
match = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
if (match) {
|
|
output.push(row);
|
|
}
|
|
}
|
|
|
|
// order the output using this.order in most performant way
|
|
if (this.order.key) {
|
|
function naturalSort(a, b) {
|
|
return a.toString().localeCompare(b.toString(), undefined, {numeric: true, sensitivity: 'base'});
|
|
}
|
|
|
|
const isDateColumn = this.columns[this.order.key].filter === 'date';
|
|
|
|
output.sort((a, b) => {
|
|
let valueA = isDateColumn ? new Date(a[this.order.key].length === 10 ? parseInt(a[this.order.key]) *
|
|
1000 : parseInt(a[this.order.key])).getTime() : a[this.order.key] || ''
|
|
let valueB = isDateColumn ? new Date(b[this.order.key].length === 10 ? parseInt(b[this.order.key]) *
|
|
1000 : parseInt(b[this.order.key])).getTime() : b[this.order.key] || ''
|
|
|
|
if (valueA === valueB) return 0;
|
|
|
|
if (this.order.order === 'asc') {
|
|
return typeof valueA === 'string' ? naturalSort(valueA, valueB) : (valueA < valueB ? -1 : 1);
|
|
} else {
|
|
return typeof valueA === 'string' ? naturalSort(valueB, valueA) : (valueA > valueB ? -1 : 1);
|
|
}
|
|
});
|
|
|
|
}
|
|
// set page to current page or 1 if page is not set or 0 or less or over max pages
|
|
if (this.pagination.page < 1) this.pagination.page = 1;
|
|
return output;
|
|
}, visibleRows() {
|
|
if (!this.rawRows || this.ssr === true) return null;
|
|
// Pagination and slice logic
|
|
this.pagination.total_pages = Math.ceil(this.computedRows.length / this.pagination.per_page);
|
|
this.pagination.filtered_available = this.computedRows.length;
|
|
if (this.pagination.page > this.pagination.total_pages) {
|
|
this.pagination.page = this.pagination.total_pages;
|
|
}
|
|
const perPage = this.pagination.per_page;
|
|
const page = this.pagination.page;
|
|
const startIndex = (page - 1) * perPage;
|
|
const endIndex = startIndex + perPage;
|
|
this.rows = this.computedRows.slice(startIndex, Math.min(endIndex, this.computedRows.length));
|
|
return this.rows;
|
|
}
|
|
|
|
}, 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) {
|
|
this.disableDebounce = true;
|
|
await this.fetchRows()
|
|
this.isInitialised = true;
|
|
}
|
|
|
|
// if sticky is true then add style element to style thead sticky
|
|
if (this.sticky) {
|
|
const style = document.createElement('style');
|
|
// 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.tt-table.${this.config.key}-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.tt-table.${this.config.key}-table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
|
|
})
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
this.debouncedHandleResize = this.debounce(this.handleResponsiveColumns, 100);
|
|
window.addEventListener('resize', this.debouncedHandleResize);
|
|
}, beforeDestroy() {
|
|
window.removeEventListener('resize', this.debouncedHandleResize);
|
|
}
|
|
})
|
|
|
|
// Vue.config.errorHandler = function (err, vm, info) {
|
|
// // still log errors to the console
|
|
// console.error(info, err, vm);
|
|
//
|
|
// if (typeof vm.config.key === 'string') {
|
|
// // check if document.querySelector table.tt-table exists aswell if it has atleast 3 <tr> elements
|
|
// const table = document.querySelector('table.tt-table');
|
|
// if (!table || !table.querySelectorAll('tr').length > 2) {
|
|
// // localStorage.removeItem(`tt-table-${vm.config.key}`);
|
|
// // window.location.reload();
|
|
// }
|
|
// }
|
|
// }
|