/** * @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: `
Einträge pro Seite
` }) Vue.component('tt-table', { template: `
{{ column.text }}
Keine Ergebnisse!
Laden...
`, 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 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(); // } // } // }