Source: common/components/table.vue

<template>
    <div>
        <div ref="header">
            <Header v-if="!detailTable && !parentPopup" :name="name ?? $route.name"
                :title="title ?? $t(name ?? $route.name)" :backButton="!parentPopup && $store.level > 1" />
        </div>
        <div @keydown="handleKeyDown($event, true)">
            <!-- Overlays for inline editing -->

            <q-input v-if="overlays.overlayInput" dense outlined class="input-box" ref="overlayInput"
                v-model="editedItem" :type="inputType(currentOverlay.col)" @blur="closeOverlay"
                @keydown="handleKeyDown($event, false)" :style="overlayStyle" @update:model-value="editedItemChanged" />
            <JsonEditor v-if="overlays.overlayJson" ref="overlayJson" v-model="editedItem" @blur="closeOverlay"
                @keydown="handleKeyDown($event, false)" :style="overlayStyle" @update:model-value="editedItemChanged" />
            <!-- <q-input v-if="overlays.overlayText" type="textarea" rows=8 ref="overlayText"
                v-model="editedItem" @blur="closeOverlay" @keydown="handleKeyDown($event, false)" :style="overlayStyle"
                :iconPicker="false" @update:model-value="editedItemChanged"/> -->
            <autocomplete v-if="overlays.overlaySelect" ref="overlaySelect" class="input-box" v-model="editedItem"
                @blur="closeOverlay" :lookup="activeLookup" :fromTable="true" @update:model-value="editedItemChanged"
                emit-value map-options :style="overlayStyle" :row="props.row" />
            <icon-picker v-if="overlays.overlayIcon" ref="overlayIcon" v-model="editedItem" @blur="closeOverlay"
                @update:model-value="editedItemChanged" :style="overlayStyle" />

            <div ref="preheader">
                <div v-if="contextValuesLoaded" class="row">
                    <autocomplete v-for="cv of contextValuesLocal" :key="cv.name" v-model="cv.value" :label="cv.label"
                        :option-label="cv.optionLabel" :option-value="cv.optionValue" dense :lookup="cv.lookup" :options="cv.options"
                        @update:model-value="contextValueChanged(cv)" :style="{ width: cv.width ?? '100px' }" map-options emit-value
                        :clearable="cv.clearable" />
                </div>
                <!-- Toolbar before the table -->
                <div class="header-container toolbar"
                    style="display: flex; justify-content: space-between; width: 100%; align-items: center;">
                    <div>
                        <q-btn :disable="$store.formChanged" v-for="action of tableActions " dense flat
                            :icon="action.icon" :color="action.iconColor ?? 'primary'" no-caps
                            @click="runTableAction(action)">
                            <div v-if="action.label" v-html="action.label"></div>
                            <q-tooltip v-if="action.tooltip">{{ $t(action.tooltip) }}</q-tooltip>
                        </q-btn>
                        <span v-if="!hideDefaultToolbar">
                            <q-btn v-if="tableAPI && asForm && !noInlineEditing" dense flat icon="table_chart"
                                color="primary" @click="inEdit = false;  asForm = false" :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Table view") }}</q-tooltip>
                            </q-btn>
                            <q-btn v-if="tableAPI && !asForm && !noInlineEditing" dense flat icon="assignment"
                                color="primary" @click="asForm = true" :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Form view") }}</q-tooltip>
                            </q-btn>
                            <q-btn dense flat icon="filter_alt" color="primary" @click="showFilter = true"
                                :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Filter form") }}</q-tooltip>
                            </q-btn>
                            <q-btn v-if="filterSet" dense flat icon="filter_alt_off" color="red" @click="clearFilter"
                                :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Clear filter") }}</q-tooltip></q-btn>
                            <q-btn dense flat icon="refresh" color="primary" @click="reload"
                                :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Reload data") }}</q-tooltip></q-btn>
                            <q-btn v-if="!asForm" dense flat :icon="grid ? 'view_list' : 'view_module'" color="primary"
                                @click="grid = !grid" :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Toggle grid view") }}</q-tooltip></q-btn>
                            <q-btn v-if="allowNew" class="text-bold" dense flat icon="add" color="primary"
                                @click="addRow()" :disable="$store.formChanged">
                                <q-tooltip>{{ $t("Add new row") }}</q-tooltip></q-btn>
                            <q-btn v-if="isAdmin" dense flat icon="picture_as_pdf" color="primary" @click="editPdf">
                                <q-tooltip>{{ $t("Edit report definition") }}</q-tooltip>
                            </q-btn>
                            <q-btn v-if="$store.formChanged" dense flat icon="save" color="positive"
                                @click="saveChanges">
                                <q-tooltip>{{ $t("Save changes") }}
                                </q-tooltip>
                            </q-btn>
                            <q-btn v-if="$store.formChanged" dense flat icon="undo" color="negative"
                                @click="undoChanges">
                                <q-tooltip>{{ $t("Undo changes") }}
                                </q-tooltip>
                            </q-btn>
                        </span>

                    </div>
                    <div v-if="!hideRecordsToolbar && !asForm">
                        <span v-if="nRows > 0">
                            {{ $t("Records") }} {{ from }}-{{ to }} {{ $t("of") }} {{ nRows }}
                        </span>
                        <span v-else>{{ $t("No data available") }}</span>
                        <q-btn v-if="from > 1" dense flat icon="first_page" color="primary"
                            @click="$refs.table.scrollTo(0)">
                            <q-tooltip>{{ $t("First record") }}</q-tooltip>
                        </q-btn>
                        <q-btn v-if="to < nRows" dense flat icon="last_page" color="primary"
                            @click="$refs.table.scrollTo(nRows - 1)">
                            <q-tooltip>{{ $t("Last record") }}</q-tooltip>
                        </q-btn>
                        <q-btn v-if="hasPdfReport" dense flat icon="preview" color="primary" @click="previewPdf">
                            <q-tooltip>{{ $t("View PDF report") }}</q-tooltip>
                        </q-btn>
                        <q-btn v-if="nRows > 0" dense flat icon="download" color="primary" @click="exportTable">
                            <q-tooltip>{{ $t("Download table") }}</q-tooltip>
                        </q-btn>
                    </div>
                    <div v-if="!hideRecordsToolbar && asForm && $refs.form">
                        <TableRecords :parent="this" />
                    </div>
                </div>
            </div>

            <!-- The table -->
            <q-table square v-if="loaded && !asForm" ref="table" class="my-sticky-header-table" @virtual-scroll="scroll"
                :table-style="tableStyle" dense flat bordered :rows="filterSet ? rowsFiltered : rows" :columns="columns"
                :visible-columns="visibleColumns" :row-key="key" virtual-scroll virtual-scroll-slice-size=1
                v-model:pagination="pagination" :rows-per-page-options="[0]" :selection="selection"
                v-model:selected="selectedRows" hide-bottom :selected-rows-label="selectedRowsLabel" :grid="grid">

                <!-- Header -->
                <template v-slot:header="props">

                    <!-- column names -->
                    <q-tr :props="props">
                        <q-th v-if="selection != 'none'" style="width:'15px'">
                            <q-checkbox dense v-if="selection == 'multiple'" v-model="props.selected" />
                        </q-th>
                        <q-th v-if="hasRowToolbar" :style="{ minWidth: rowToolbarWidth + 'px' }">
                        </q-th>
                        <q-th v-for="(col, index) in props.cols " :key="col.name" :props="props"
                            :ref="'col-' + col.name" :style="{ textAlign: col.align }">
                            <span class="text-bold" v-html="col.label" :style="{ display: 'inline-block' }"></span>
                        </q-th>
                    </q-tr>

                    <!-- filter row -->
                    <q-tr :props="props">
                        <q-td v-if="selection != 'none'" style="width:'15px'" />
                        <q-td v-if="hasRowToolbar" />
                        <q-td v-for="col in props.cols" :key="col.name" :props="props" class="q-pa-none  q-ma-none">
                            <q-checkbox v-if="col.type == 'boolean'" v-model="filter[col.name]" dense
                                toggle-indeterminate />
                            <q-input v-else type="search" placeholder="Search" clearable dense
                                v-model="filter[col.name]" class="q-my-none q-py-none">
                                <template v-slot:before>
                                    <q-icon size="xs" name="search"></q-icon>
                                </template>
                            </q-input>
                        </q-td>
                    </q-tr>
                </template>

                <!-- grid -->
                <template v-slot:item="props">
                    <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition">
                        <!-- :style="props.selected ? 'transform: scale(0.95);' : ''"> -->
                        <q-card flat>
                            <q-separator></q-separator>
                            <!-- :class="props.selected ? 'bg-grey-2' : ''"> -->
                            <q-card-section v-if="selection != 'none' || hasRowToolbar">
                                <q-checkbox v-if="selection != 'none'" dense v-model="props.selected"
                                    :label="props.row.name"></q-checkbox>
                                <table-row-toolbar :parent="this" :props="props" :columns="columns"
                                    :rowActions="rowActions" />
                            </q-card-section>
                            <q-list dense>
                                <q-item v-for="col in props.cols" :key="col.name">
                                    <span class="label">{{ col.label }}</span>&nbsp;<q-space />
                                    <span @click="showOverlay(col, props)">
                                        <q-checkbox dense v-if="col.type == 'boolean'" v-model="props.row[col.index]"
                                            :disable="!allowEdit || col.disabled || noInlineEditing"
                                            @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                                        <span v-else-if="col.type == 'rating'">
                                            <q-rating v-model="props.row[col.index]" :max="col.max" :size="col.size"
                                                :color="col.color" :color-selected="col.colorSelected"
                                                :color-half="col.colorHalf" :icon="col.icon"
                                                :icon-selected="col.iconSelected" :icon-half="col.iconHalf"
                                                :disable="!allowEdit || col.disabled || noInlineEditing"
                                                @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                                            {{ props.row[col.index] }}
                                        </span>
                                        <span v-else :ref="props.key + '-' + col.index"
                                            v-html="col.password ? '********' : col.value" class="q-pa-none q-ma-none"
                                            :style="{ display: 'inline-block', overflow: 'hidden', maxWidth: col.width + '!important', maxHeight: col.height + '!important', verticalAlign: 'middle' }" />
                                    </span>
                                    <q-btn v-if="col.url && props.row[col.index]" dense flat icon="open_in_new"
                                        @click="openURL(col.value)" class="q-pa-none q-ma-none"></q-btn>
                                </q-item>
                            </q-list>
                        </q-card>
                    </div>
                </template>

                <!-- normal rows -->
                <template v-slot:body="props">
                    <q-tr :props="props"
                        :class="(props.pageIndex % 2 == 0) ? 'background' + (this.$q.dark.isActive ? '-dark' : '') : ''">
                        <q-td v-if="selection != 'none'">
                            <q-checkbox dense v-model="props.selected" />
                        </q-td>
                        <q-td v-if="hasRowToolbar">
                            <table-row-toolbar :parent="this" :props="props" :columns="columns"
                                :rowActions="rowActions" />
                        </q-td>
                        <q-td v-for="   col in props.cols   " :key="col.name" :props="props"
                            :ref="props.key + '-' + col.index"
                            :style="{ maxWidth: col.width + ' !important', verticalAlign: 'middle', textAlign: col.align }"
                            @click="showOverlay(col, props)" class="q-pa-none q-ma-none">
                            <q-checkbox dense v-if="col.type == 'boolean'" v-model="props.row[col.index]"
                                :disable="!col.enabled && (!allowEdit || col.disabled || noInlineEditing)"
                                @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                            <span v-else-if="col.type == 'rating'">
                                <q-rating v-model="props.row[col.index]" :max="col.max" :color="col.color"
                                    :color-selected="col.colorSelected" :color-half="col.colorHalf" :icon="col.icon"
                                    :icon-selected="col.iconSelected" :icon-half="col.iconHalf" :size="col.size"
                                    :disable="!allowEdit || col.disabled || noInlineEditing"
                                    @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                                {{ props.row[col.index] }}
                            </span>
                            <div v-else-if="col.url && props.row[col.index]" class="cell-content">
                                <span v-html="col.password ? '********' : col.value" class="q-pa-none q-ma-none"
                                    :style="{ display: 'inline-block', overflow: 'hidden', maxWidth: col.width + '!important', maxHeight: col.height + '!important', verticalAlign: 'middle' }" />
                                <q-btn v-if="col.url" dense flat icon="open_in_new" @click.stop="openURL(col.value)"
                                    class="q-pa-none q-ma-none right-in-cell"></q-btn>
                            </div>
                            <span v-else v-html="col.password ? '********' : col.value" class="q-pa-none q-ma-none"
                                :style="{ display: 'inline-block', overflow: 'hidden', maxWidth: col.width + '!important', maxHeight: col.height + '!important', verticalAlign: 'middle' }" />
                        </q-td>
                    </q-tr>
                </template>
                <template v-slot:top-row="props">
                    <q-tr v-if="summary_top">
                        <q-td v-if="selection != 'none'">
                        </q-td>
                        <q-td v-if="hasRowToolbar">
                        </q-td>
                        <q-td v-for="col in props.cols" :key="col.name" :class="col.__thClass">
                            <span class="summary-bottom">{{ calc(col, summary_top) }}</span>
                        </q-td>
                    </q-tr>
                </template>
                <template v-slot:bottom-row="props">
                    <q-tr v-if="summary">
                        <q-td v-if="selection != 'none'">
                        </q-td>
                        <q-td v-if="hasRowToolbar">
                        </q-td>
                        <q-td v-for="col in props.cols" :key="col.name" :class="col.__thClass">
                            <span class="summary-top">{{ calc(col, summary) }}</span>
                        </q-td>
                    </q-tr>
                </template>
            </q-table>

            <table-filter v-if="showFilter" :parent="this" @cancel="showFilter = false" />

            <!-- Form view -->
            <table-row-editor v-if="loaded && asForm" ref="form" @save="save" :multiRow="true" :parent="this"
                :rows="filterSet ? rowsFiltered : rows" @cancel="cancel" />

            <!-- Dialog for editing a single row -->
            <q-dialog v-if="inEdit && !asForm" :model-value="true" ref="popupForm" @keydown="handleSaveCancelKeydown"
                persistent>
                <table-row-editor v-if="inEdit" :parent="this" @save="save" :rows="filterSet ? rowsFiltered : rows"
                    @cancel="inEdit = false" />
            </q-dialog>
        </div>
    </div>
</template>

<script>

/**
 * Generic table component
 * 
 * @component
 * @name Table
 * @example
 * <Table />
 */

import { TableEditMixin } from '../mixins/table-edit.js';
import { TableMixin } from '../mixins/table.js';
import { TableUtilsMixin } from '../mixins/table-utils.js';
import { TableCustomMixin } from '@/specific/mixins/table-custom.js';
import { loadComponent } from '@/common/component-loader';
import eventBus from '@/common/event-bus';

export default {
    name: "Table",
    mixins: [TableMixin, TableEditMixin, TableCustomMixin, TableUtilsMixin],
    components: {
        TableRowEditor: loadComponent("table-row-editor"),
        TableRowToolbar: loadComponent("table-row-toolbar"),
        TableFilter: loadComponent("table-filter"),
        Autocomplete: loadComponent("autocomplete"),
        JsonEditor: loadComponent("json-editor"),
        IconPicker: loadComponent("icon-picker"),
        TableRecords: loadComponent("table-records"),
    },
    emits: ['close'],
    watch: {
        '$route.query.timestamp': {
            handler(val) {
                console.log('timestamp changed', val);
                this.init();
            },
            immediate: true
        },
    },
    data: () => ({
        rowsFiltered: [],
        hasPdfReport: null,
    }),
    computed: {

        /**
         * Determines if the row toolbar should be displayed.
         * The toolbar is shown if any of the following conditions are met:
         * - Editing is allowed (`allowEdit` is true)
         * - Deleting is allowed (`allowDelete` is true)
         * - There are specific row actions defined (`rowActions` is true)
         * - The user has admin privileges (`isAdmin` is true)
         *
         * @returns {boolean} True if the row toolbar should be displayed, otherwise false.
         */
        hasRowToolbar() {
            return this.allowEdit || this.allowDelete || this.rowActions || this.isAdmin;
        },

        rowToolbarWidth() {
            let w = 30; // padding is 24px
            let bW = this.btnSize == 'sm' ? 20 : 30;
            let tW = this.btnSize == 'sm' ? 45 : 60;
            if (this.allowEdit) w += bW;
            if (this.allowDelete) w += bW;
            if (this.isAdmin) w += bW;
            if (this.rowActions) {
                for (let action of this.rowActions) {
                    w += bW;
                    if (action.label) w += tW * action.label.length / 7; // assumes that average label has 7 characters
                }
            }
            return w;
        },
        /**
         * Returns a boolean value indicating whether the filter is set for any column.
         * 
         * @returns {boolean} True if the filter is set for any column, otherwise false.
         */
        filterSet() {
            if (!this.columns) return false;
            let ret = this.columns.filter(col => this.filterExp[col.name] == "set"
                || this.filterExp[col.name] == "not set"
                || (this.filter[col.name] != undefined && this.filter[col.name].toString() != "")
                || (this.filter2[col.name] != undefined && this.filter2[col.name].toString() != "")
            );
            if (ret.length > 0) {
                let f = "";
                for (let col of ret) {
                    if (f != "") f += " && ";
                    let index = this.frugal ? col.index : '"' + col.name + '"';
                    if (this.filterExp[col.name] == 'set') {
                        f += `row[${index}] != null`;
                    } else if (this.filterExp[col.name] == 'not set') {
                        f += `row[${index}] == null`;
                    } else if (col.type == "boolean") {
                        f += `row[${index}] == ${this.filter[col.name]}`;
                    } else if (this.filterExp[col.name] == 'contains') {
                        f += `(row[${index}] ?? "").toString().toLowerCase().includes("${this.filter[col.name].toLowerCase()}")`;
                    } else if (this.filterExp[col.name] == '!contains') {
                        f += `!row[${index}].toString().toLowerCase().includes("${this.filter[col.name].toLowerCase()}")`;
                    } else if (this.filterExp[col.name] == '=') {
                        f += `this.realValue("${col.type}", row[${index}]) == "${this.filter[col.name].toLowerCase()}"`;
                    } else if (this.filterExp[col.name] == '!=') {
                        f += `this.realValue("${col.type}", row[${index}]) != "${this.filter[col.name].toLowerCase()}"`;
                    } else if (this.filterExp[col.name] == '>') {
                        f += `this.realValue("${col.type}", row[${index}]) > ${this.filter[col.name]}`;
                    } else if (this.filterExp[col.name] == '>=') {
                        f += `this.realValue("${col.type}", row[${index}]) >= ${this.filter[col.name]}`;
                    } else if (this.filterExp[col.name] == '<') {
                        f += `this.realValue("${col.type}", row[${index}]) < ${this.filter[col.name]}`;
                    } else if (this.filterExp[col.name] == '<=') {
                        f += `this.realValue("${col.type}", row[${index}]) <= ${this.filter[col.name]}`;
                    } else if (this.filterExp[col.name] == 'between') {
                        f += `this.realValue("${col.type}", row[${index}]) >= ${this.filter[col.name]} && this.realValue("${col.type}", row[${index}]) <= ${this.filter2[col.name]}`;
                    }
                }
                f = `return ${f}`;
                let filterFunction = new Function("row", f);
                filterFunction = filterFunction.bind(this);
                this.rowsFiltered = this.rows.filter(filterFunction);
                return true;
            }
            return false;
        },

        /**
         * Returns the size of the button based on the screen size.
         * 
         * @returns {string} The size of the button ('sm' for small or 'md' for medium).
         */
        btnSize() {
            return this.$q.screen.gt.sm ? 'sm' : 'md';
        },

        /**
         * Returns the number of rows in the table.
         * If the rows array is empty, it returns 0.
         * If the filterSet is enabled, it returns the length of the filtered rows array.
         * Otherwise, it returns the length of the rows array.
         *
         * @returns {number} The number of rows in the table.
         */
        nRows() {
            if (!this.rows) return 0;
            return this.filterSet ? this.rowsFiltered.length : this.rows.length;
        },

        /**
         * Calculates the height of the table component.
         * 
         * @returns {number} The calculated height of the table.
         */
        height() {
            //await this.$nextTick();
            if (this.$refs.header && this.$refs.preheader) {
                if (!this.detailTable && !this.parentPopup) {
                    //if (!this.$refs.preheader || !this.$refs.header) return 0;            
                    return this.$q.screen.height - this.$refs.header.offsetHeight - 40 - this.$refs.preheader.offsetHeight - 5;
                } else {
                    //if (!this.$refs.preheader) return 0;
                    return this.$q.screen.height - 150 - this.$refs.preheader.offsetHeight;
                }
            }
        },

        /**
         * Returns the style for the table.
         * @returns {string} The style for the table.
         */
        tableStyle() {
            return `height: ${this.height}px; width: ${this.$store.screenWidth - 5}px;`;
        },
    },
    methods: {

        contextValueChanged(cv) {
            let label;
            if (cv.value == null) {
                label = '';
            } else {
                let v;
                if (cv.lookup) {
                    v = cv.lookup.options.find(x => x[cv.lookup.optionValue] == cv.value);
                    label = v[cv.lookup.optionLabel];
                } else {
                    v = cv.options.find(x => x.value == cv.value);
                    label = v.label;
                }
            }
            this.$store.contextValues[cv.name] = { value: cv.value, label: label };
            this.$q.localStorage.setItem("context_value_" + cv.name, this.$store.contextValues[cv.name]);               
            this.reload();
        },

        async moveTo(index) {
            await this.editRow(this.rows[index]);
            this.editingRowIndex = index;
            this.copyObject(this.editingRow, this.editingRowSaved);
            this.$store.formChanged = false;    
        },

        async saveForm() {
            await this.saveRow();
            if (this.editMode == "add") {
                await this.addRow();
                //this.editingRowIndex = this.rows.length - 1;
            }
            this.copyObject(this.editingRow, this.editingRowSaved);
            this.$store.formChanged = false;
        },

        async saveChanges() {
            let formToValidate = this.$refs.form ?? this.$refs.popupForm;
            if (formToValidate) {
                if (await this.validateForm(formToValidate.form)) {
                    await this.saveForm();
                } 
            } else {
                await this.saveRows();
            }
            this.$store.formChanged = false;
        },

        async undoChanges() {
            if (this.asForm) {
                await this.$refs.form.cancel();
            } else {
                await this.reload();                
            }
            this.$store.formChanged = false;
        },
        
        async save() {
            await this.saveRow();
            this.$emit('close');
            this.inEdit = false;
        },

        cancel() {
            console.log('table cancel');
            this.$emit('close');
        },

        /**
         * Converts the given value based on the column type.
         * 
         * @param {Object} col - The column object.
         * @param {any} val - The value to be converted.
         * @returns {any} - The converted value.
         */
        realValue(type, val) {
            if (type == "timestamp with time zone") {
                return this.toLocalISOString(new Date(val));
            } else if (type == "string" || type == "text" || type == "character varying" || type == "character") {
                return val?.toLowerCase();
            } else if (val == null) {
                return '';
            } else {
                return val;
            }
        },

        /**
         * Calculate the summary of a column
         * @param {object} col - column 
         * @param {object} summary 
         */
        calc(col, summary) {
            let name = col.name;
            if (summary.labelColumn && name == summary.labelColumn.name) {
                return summary.labelColumn.text;
            }
            let type = summary[name];
            if (type == null) return '';
            if (this.frugal) {
                name = this.columns.find(x => x.name == name).index;
            }
            let res;
            if (type == 'sum') {
                res = this.rowsFiltered.reduce((acc, row) => acc + row[name], 0);
            } else if (type == 'count') {
                res = this.rowsFiltered.length;
            } else if (type == 'mean') {
                res = this.rowsFiltered.reduce((acc, row) => acc + row[name], 0) / this.rows.length;
            } else if (type == 'min') {
                res = this.rowsFiltered.reduce((acc, row) => Math.min(acc, row[name]), Infinity);
            } else if (type == 'max') {
                res = this.rowsFiltered.reduce((acc, row) => Math.max(acc, row[name]), -Infinity);
            }
            return col.format(res);
        },
        async calculateColumnWidths() {
            await this.$nextTick();
            for (let col of this.columns) {
                let el = this.$refs["col-" + col.name]?.[0];
                if (!el) continue;
                el = el && el.$el ? el.$el : el;
                if (!el) continue;
                const width = el.offsetWidth;
                // check that column's width is not set with colAtts
                if (!(typeof col.width === 'string' && col.width.endsWith('px'))) {
                    col.width = width;
                }
            }
        },
        /**
         * Scroll event
         * @param {*} details 
         */
        async scroll(pos) {
            this.from = pos.from + 1;
            this.to = pos.to + 1;
            this.closeAllOverlays();
            await this.calculateColumnWidths();
        },
        /**
         * Text displaying the number of selected rows
         */
        selectedRowsLabel() {
            this.selectedRows.length + ' ' + this.$t('selected');
        },

        /**
         * Handles the keydown event.
         * 
         * @param {Event} event - The keydown event object.
         * @param {boolean} isParent - Indicates whether the event is triggered by a parent component.
         */
        async handleKeyDown(event, isParent) {
            if (event.ctrlKey && event.key === 'f') {
                this.showFilter = true;
                event.preventDefault();
                this.closeOverlay();
            } else if (event.altKey && event.key === 'a') {
                this.addRow();
                event.preventDefault();
                this.closeOverlay();
            } else if (event.ctrlKey && event.key === 's') {
                event.preventDefault();
                event.stopPropagation();
                this.saveChanges();
                this.closeOverlay();
            } else if (event.ctrlKey && event.key === 'u') {
                this.undoChanges();
                event.preventDefault();
                this.closeOverlay();
            } else if (event.key === 'Escape') {
                this.closeOverlay();
            } else if (event.key === 'Tab') {
                if (this.currentOverlay) {
                    let o = this.currentOverlay;
                    this.closeOverlay();
                    event.preventDefault();
                    await this.$nextTick();
                    let col = o.props.cols[o.col.index + 1];
                    if (col) {
                        this.showOverlay(col, o.props)
                    }
                }
                //this.closeOverlay();
            //} else if (!isParent) {
            //    this.editedItemChanged();
            }
        },

        getApiAndOptions() {
            let api = null,
                apiOptions = {};
            let frugal = this.frugal;
            if (this.dbFunction) {
                api = ' Table/GetTable';
                apiOptions = {
                    dbFunction: this.dbFunction,
                    json: this.json.toString(),
                    pars: JSON.stringify(this.params) ?? '{}',
                    preprocess: this.preprocess ?? null,
                };
            } else if (this.restAPI) {
                api = this.restAPI;
                for (const key in this.params) {
                    api += '/' + this.params[key];
                }
            } else if (this.tableAPI) {
                frugal = true;
                if (this.params) {
                    api = 'Table/' + this.tableAPI;
                    apiOptions = { pars: JSON.stringify(this.params) };
                } else if (this.tableAPIKey) {
                    api = 'Table/' + this.tableAPI + '/' + this.tableAPIKey;
                } else {
                    api = 'Table/' + this.tableAPI;
                }
            }
            apiOptions.frugal = frugal.toString();
            return { api, apiOptions };
        },
        editPdf() {
            const { api, apiOptions } = this.getApiAndOptions();
            let rows = this.filterSet ? this.rowsFiltered : this.rows;
            const isFrugal = this.frugal === true;
            if (isFrugal) {
                const visibleColumnIndexes = new Set(
                    this.visibleColumns.map((columnName) =>
                        this.columns.findIndex(
                            (column) => column.name === columnName,
                        ),
                    ),
                );
                rows = rows.map((row) =>
                    row.filter((_, index) => visibleColumnIndexes.has(index)),
                );
            }
            const contextValuesName = this.contextValuesLoaded
                ? this.contextValuesLocal
                      .map(
                          (contextValue) =>
                              contextValue.options.find(
                                  (option) => option.id === contextValue.value,
                              ).name,
                      )
                      .join('__')
                : null;
            const maximumElementWidth = 500;
            const columnWidths = this.visibleColumns.map((columnName) => {
                const column = this.columns.find(
                    (column) => column.name === columnName,
                );
                if (Number.isFinite(column.width)) {
                    return column.width;
                }
                let widthBasedOnType = Number(
                    (this.colWidths?.[this.columns[0].type] ?? '').replace(
                        'px',
                        '',
                    ),
                );
                if (Number.isNaN(widthBasedOnType)) {
                    return maximumElementWidth;
                }
                return widthBasedOnType;
                // return Math.min(column.width, widthBasedOnType);
            });
            this.editPdfPopup = this.initPopup ({
                component: 'pdf-report-editor',
                title: `PDF Report Editor\u00A0\u00A0·\u00A0\u00A0${this.name}`,
                apiUrl: api,
                apiOptions: apiOptions ?? null,
                data: {
                    columns: this.visibleColumns,
                    rows,
                    isFrugal,
                    columnWidths,
                },
                tableName: this.name,
                contextValuesName,
                // TODO: added persistent because @hide event is not
                // called on q-dialog in popup.vue
                // when click is made outside of dialog to close it
                persistent: true,
            });
            // maybe event bus should be moved to mounted and beforeUnmount
            const onPopupClosed = async (popupName) => {
                if (popupName == this.editPdfPopup && !this.$store.formChanged) {
                    this.hasPdfReport = await this.getHasPdfReport();
                    eventBus.off('popupClosed', onPopupClosed);
                }
            };
            eventBus.on('popupClosed', onPopupClosed);
        },
        async getPdfReportRow() {
            const requestApi = 'Table/general_pdf_template';
            // optimize this get request(s)
            const pdfReportTemplates = await this.get(requestApi);
            if (pdfReportTemplates) {
                const nameIndexInData = pdfReportTemplates.attributes.findIndex(
                    (attribute) => attribute.name === 'name',
                );
                const contextValuesName = this.contextValuesLoaded
                    ? this.contextValuesLocal
                          .map(
                              (contextValue) =>
                                  contextValue.options?.find(
                                      (option) =>
                                          option.id === contextValue.value,
                                  )?.name,
                          )
                          .join('__')
                    : null;
                    let savedRow;
                if (contextValuesName !== null) {
                    const name = this.name + ' - ' + contextValuesName;
                    savedRow = pdfReportTemplates.data.find(
                        (row) => row[nameIndexInData] === name,
                    );
                }
                if (savedRow === undefined) {
                    savedRow = pdfReportTemplates.data.find(
                        (row) => row[nameIndexInData] === this.name,
                    );
                }
                if (savedRow !== undefined) {
                    return Object.fromEntries(
                        pdfReportTemplates.attributes.map(
                            (attribute, index) => [
                                attribute.name,
                                savedRow[index],
                            ],
                        ),
                    );
                }
            }
            return null;
        },
        async getHasPdfReport() {
            const row = await this.getPdfReportRow();
            return row !== null
        },
        async previewPdf() {
            const row = await this.getPdfReportRow();
            if (row === null) {
                return;
            }
            this.initPopup({
                component: 'pdf-report-preview',
                
                title: `PDF Report Preview\u00A0\u00A0·\u00A0\u00A0${row.name}`,
                state: JSON.parse(row.state),
                canCloseIfFormChanged: true,
            });
        },
    },
}
</script>

<style scoped>
.label {
    color: gray;
}

.toolbar {
    top: 76px;
    width: 100%;
    z-index: 100;
}

.my-sticky-header-table {
    /* background-color: white; */
}

.my-sticky-header-table thead tr:first-child th {
    /* background-color: #1976D2; */
    background-color: var(--q-primary);
    color: white;
}

.my-sticky-header-table {
    top: 0px !important;
}

.my-sticky-header-table thead tr:first-child th {
    position: sticky;
    z-index: 1;
    top: 0px;
}

:deep(.input-box .q-field__control),
:deep(.input-box .q-field__marginal) {
    height: 32px;
    padding: 0px 2px 2px 2px;
    border-radius: 0px;
    /* background-color: white; */
}

:deep(.text-box .q-field__control),
:deep(.text-box .q-field__marginal) {
    height: 100px;
    padding: 0px 2px 2px 2px;
    border-radius: 0px;
    /* background-color: white; */
}

</style>