diff --git a/application/controllers/api/v1/system/Tag.php b/application/controllers/api/v1/system/Tag.php index 7ce375217..df3003b44 100644 --- a/application/controllers/api/v1/system/Tag.php +++ b/application/controllers/api/v1/system/Tag.php @@ -37,6 +37,7 @@ class Tag extends API_Controller if (isset($tag)) { + $this->TagModel->addJoin('public.tbl_notiz_typ', 'typ_kurzbz', 'typ_kurzbz'); $result = $this->TagModel->load($tag); $this->response($result, REST_Controller::HTTP_OK); diff --git a/application/core/Tag_Controller.php b/application/core/Tag_Controller.php index 2963c0fdf..9d2f4cf6a 100644 --- a/application/core/Tag_Controller.php +++ b/application/core/Tag_Controller.php @@ -73,6 +73,7 @@ class Tag_Controller extends FHCAPI_Controller tbl_notiz.notiz_id, tbl_notiz_typ.style, tbl_notiz_typ.automatisiert, + tbl_notiz_typ.prioritaet, tbl_notiz.erledigt as done, tbl_notiz.insertamum, tbl_notiz.updateamum, @@ -107,7 +108,8 @@ class Tag_Controller extends FHCAPI_Controller style, beschreibung, tag, - automatisiert + automatisiert, + prioritaet " ); $this->NotiztypModel->addOrder('prioritaet'); @@ -317,6 +319,7 @@ class Tag_Controller extends FHCAPI_Controller tbl_notiz.notiz_id, tbl_notiz_typ.style, tbl_notiz_typ.automatisiert, + tbl_notiz_typ.prioritaet, tbl_notiz.erledigt as done, tbl_notiz.insertamum, tbl_notiz.updateamum, @@ -336,6 +339,8 @@ class Tag_Controller extends FHCAPI_Controller $this->NotizModel->addJoin('public.tbl_notizzuordnung notizzuordnung', 'tbl_notiz.notiz_id = notizzuordnung.notiz_id'); + $this->NotizModel->addOrder('done'); + $this->NotizModel->addOrder('tbl_notiz_typ.prioritaet'); $notiz = $this->NotizModel->loadWhere(array('prestudent_id' => $prestudent_id)); diff --git a/application/libraries/stv/StudentListLib.php b/application/libraries/stv/StudentListLib.php index bafa1ca46..293d74932 100644 --- a/application/libraries/stv/StudentListLib.php +++ b/application/libraries/stv/StudentListLib.php @@ -346,7 +346,7 @@ class StudentListLib $subQueryTag = "( SELECT tag.prestudent_id, - COALESCE(json_agg(tag ORDER BY tag.done), '[]'::json) AS tags + COALESCE(json_agg(tag ORDER BY tag.done, tag.prioritaet), '[]'::json) AS tags FROM ( SELECT DISTINCT ON (n.notiz_id) n.notiz_id AS id, @@ -355,6 +355,8 @@ class StudentListLib n.text AS notiz, nt.style, n.erledigt AS done, + nt.prioritaet AS prioritaet, + nt.automatisiert AS automatisiert, nz.prestudent_id, n.start, n.ende diff --git a/application/models/education/Lehreinheit_model.php b/application/models/education/Lehreinheit_model.php index 2f955c295..808c4e443 100644 --- a/application/models/education/Lehreinheit_model.php +++ b/application/models/education/Lehreinheit_model.php @@ -719,7 +719,7 @@ EOSQL; return "tag_data_agg AS ( SELECT lehreinheit_id, - COALESCE(json_agg(tag ORDER BY done), '[]'::json) AS tags + COALESCE(json_agg(tag ORDER BY done, prioritaet), '[]'::json) AS tags FROM ( SELECT DISTINCT ON (public.tbl_notiz.notiz_id) tbl_notiz.notiz_id AS id, @@ -728,7 +728,9 @@ EOSQL; text AS notiz, style, erledigt AS done, - lehreinheit_id + lehreinheit_id, + prioritaet, + automatisiert FROM public.tbl_notizzuordnung JOIN public.tbl_notiz ON tbl_notizzuordnung.notiz_id = tbl_notiz.notiz_id JOIN public.tbl_notiz_typ ON tbl_notiz.typ = tbl_notiz_typ.typ_kurzbz diff --git a/application/views/jssnippets/tabulatorcolumns/lvverwaltung.js b/application/views/jssnippets/tabulatorcolumns/lvverwaltung.js index 60d156a8a..9b7c44212 100644 --- a/application/views/jssnippets/tabulatorcolumns/lvverwaltung.js +++ b/application/views/jssnippets/tabulatorcolumns/lvverwaltung.js @@ -5,8 +5,7 @@ return [ field: "lv_kurzbz", headerFilterFuncParams: { field: 'lv_kurzbz' }, headerFilter: true, - formatter(cell, - formatterParams) { + formatter(cell, formatterParams) { const rowData = cell.getRow().getData(); const iconKey = (rowData.lehrtyp_kurzbz || '').toLowerCase(); const lvkurzbz = (cell.getValue()).toUpperCase(); @@ -22,8 +21,7 @@ return [ return parentspan; }, - cellClick(e, - cell) { + cellClick(e, cell) { cell.getRow().treeToggle(); } }, diff --git a/application/views/jssnippets/tabulatorcolumns/stv.js b/application/views/jssnippets/tabulatorcolumns/stv.js index 54a370f5d..8270defe1 100644 --- a/application/views/jssnippets/tabulatorcolumns/stv.js +++ b/application/views/jssnippets/tabulatorcolumns/stv.js @@ -268,9 +268,9 @@ return [ }, headerFilter: "tickCross", headerFilterParams: { - "tristate": true, + tristate: true, elementAttributes: { - "value": "true" + value: "true" } }, headerFilterEmptyCheck(value) { @@ -326,9 +326,9 @@ return [ }, headerFilter: "tickCross", headerFilterParams: { - "tristate": true, + tristate: true, elementAttributes: { - "value": "true" + value: "true" } }, headerFilterEmptyCheck(value) { diff --git a/public/js/components/DetailHeader/DetailHeader.js b/public/js/components/DetailHeader/DetailHeader.js index 84e462df3..2a5987a9b 100644 --- a/public/js/components/DetailHeader/DetailHeader.js +++ b/public/js/components/DetailHeader/DetailHeader.js @@ -298,7 +298,15 @@ export default { return this.$api .call(ApiTag.getAllTagsPrestudent({prestudent_id})) .then(result => { - this.tagData = result.data; + this.tagData = result.data.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + + if (adone !== bdone) { + return adone - bdone; + } + return a.prioritaet - b.prioritaet; + }); }) .catch(this.$fhcAlert.handleSystemError); }, diff --git a/public/js/components/LVVerwaltung/Setup/Table.js b/public/js/components/LVVerwaltung/Setup/Table.js index 28c7ceb87..0262b957b 100644 --- a/public/js/components/LVVerwaltung/Setup/Table.js +++ b/public/js/components/LVVerwaltung/Setup/Table.js @@ -2,8 +2,16 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; import BsModal from "../../Bootstrap/Modal.js"; import DetailsForm from "../Details/Form.js"; import CoreTag from '../../Tag/Tag.js'; -import { tagHeaderFilter } from '../../../../js/tabulator/filters/extendedHeaderFilter.js'; -import { extendedHeaderFilter } from "../../../../js/tabulator/filters/extendedHeaderFilter.js"; +import { + buildTagHeaderFilterExpression, + buildTagOptionsFromRows, + customTagFilter, + setTagHeaderFilterValue, + tagHeaderFilter, + extendedHeaderFilter, + syncTagHeaderFilterOptions, + syncSelectedTagOptionsWithHeaderFilters +} from "../../../../js/tabulator/filters/extendedHeaderFilter.js"; import { tagFormatter } from "../../../../js/tabulator/formatter/tags.js"; import { addTagInTable, deleteTagInTable, updateTagInTable } from "../../../../js/helpers/TagHelper.js"; @@ -69,7 +77,20 @@ export default { this.lv_info_default.studiensemester_kurzbz = newVal this.lv_info = false; } - } + }, + "tagFilterState.selectedOptions": { + handler() { + const selectedOptions = this.tagFilterState.selectedOptions; + const combinedFilterStatement = + buildTagHeaderFilterExpression(selectedOptions); + + setTagHeaderFilterValue( + combinedFilterStatement, + this.$refs.table.tabulator + ); + }, + deep: true, + }, }, data() { return { @@ -100,8 +121,37 @@ export default { handler: (data) => { this.getExpandedRows() } - } + }, + { + event: "dataLoaded", + handler: (data) => { + syncTagHeaderFilterOptions( + Array.isArray(data) ? data : [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); + }, + }, + { + event: "dataFiltered", + handler: (filters, rows) => { + syncSelectedTagOptionsWithHeaderFilters( + filters, + this.tagFilterState.selectedOptions, + this.tagsEnabled, + ); + }, + }, + { + event: "columnWidth", + handler: (column) => { + if (column.getField() !== "tags") return; + column.getCells().forEach((cell) => { + cell.getElement().firstElementChild?.fitTags?.(); + }); + }, + }, ], formData: {}, lv_info: false, @@ -115,7 +165,20 @@ export default { raumtypalternativ: this.lehreinheitRaumtypAlternativeDefault, lehrfach_id: '' - } + }, + tagFilterState: { + initialOptions: [], + selectedOptions: [], + }, + tagFilterLabels: { + tag: "Tag", + clear: "Clear", + connectors: { + AND: "AND", + OR: "OR", + NOT: "NOT", + }, + }, } }, computed: { @@ -146,6 +209,14 @@ export default { layout: 'fitDataStretch', height: '100%', persistenceID: 'lehrveranstaltungen_2026_07_15_v1', + persistence: { + sort: true, + columns: ["width", "visible"], + filter: false, + headerFilter: false, + group: false, + page: false, + }, selectableRowsRangeMode: 'click', selectableRows: true, rowContextMenu: (component, e) => { @@ -204,11 +275,25 @@ export default { created() { Tabulator.extendModule("format", "formatters", { tagHeaderFilter, - tagFormatter: cell => tagFormatter(cell, this.$refs.tagComponent), + tagFormatter: (cell, params, onRendered) => tagFormatter(cell, this.$refs.tagComponent, onRendered), ja_nein: cell => cell.getValue() ? this.toUpperCase(this.$p.t('ui', 'ja')) : this.toUpperCase(this.$p.t('ui', 'nein')) }); + LvvColumns.foreach(col => { + if (col.headerFilter == 'input' && col.headerFilterFunc == 'tagHeaderFilter') { + col.headerFilter = customTagFilter; + col.headerFilterParams = { + listOnEmpty: true, + autocomplete: true, + sort: "asc", + initialOptions: this.tagFilterState.initialOptions, + selectedOptions: this.tagFilterState.selectedOptions, + labels: this.tagFilterLabels, + }; + col.headerSort = false; + } + }); }, mounted() { if (this.shouldAutoLoad()) @@ -319,16 +404,31 @@ export default { }, addedTag(addedTag) { - addTagInTable(addedTag, this.allRows, 'lehreinheit_id') + addTagInTable(addedTag, this.allRows, 'lehreinheit_id'); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, deletedTag(deletedTag) { deleteTagInTable(deletedTag, this.allRows); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, updatedTag(updatedTag) { updateTagInTable(updatedTag, this.allRows); + syncTagHeaderFilterOptions( + this.$refs.table?.tabulator?.getData() || [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); }, async copyLehreinheit(row, art) { @@ -502,4 +602,4 @@ export default { ` -}; \ No newline at end of file +}; diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 899ec4ffa..095e34d3f 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -1,7 +1,14 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; import ListNew from './List/New.js'; import CoreTag from '../../Tag/Tag.js'; -import { tagHeaderFilter } from "../../../tabulator/filters/extendedHeaderFilter.js"; +import { + buildTagHeaderFilterExpression, + customTagFilter, + setTagHeaderFilterValue, + syncSelectedTagOptionsWithHeaderFilters, + syncTagHeaderFilterOptions, + tagHeaderFilter, +} from "../../../tabulator/filters/extendedHeaderFilter.js"; import { addTagInTable, deleteTagInTable, updateTagInTable } from "../../../../js/helpers/TagHelper.js"; import { tagFormatter } from "../../../../js/tabulator/formatter/tags.js"; @@ -18,568 +25,756 @@ import ModuleFilterFilters from '../../../tabulator/filter/filters.js'; Tabulator.extendModule("filter", "filters", ModuleFilterFilters); export default { - name: "ListPrestudents", - components: { - CoreFilterCmpt, - ListNew, - CoreTag, - ListFilter - }, - directives: { - draggable - }, - inject: { - lists: { - from: 'lists', - required: true - }, - $reloadList: { - from: '$reloadList', - required: true - }, - currentSemester: { - from: 'currentSemester', - required: true - }, - tagsEnabled: { - from: 'configStvTagsEnabled', - default: false - }, - }, - props: { - selected: Array, - studiengangKz: Number, - studiensemesterKurzbz: String - }, - emits: [ - 'update:selected', - 'filterActive' - ], - data() { - return { - tabulatorOptions: { - columns: StvColumns, - locale: true, - rowFormatter(row) { - if (row.getData().bnaktiv === false) { - row.getElement().classList.add('text-black','text-opacity-50','fst-italic'); - } - row.getElement().draggable = true - }, + name: "ListPrestudents", + components: { + CoreFilterCmpt, + ListNew, + CoreTag, + ListFilter + }, + directives: { + draggable + }, + inject: { + lists: { + from: 'lists', + required: true + }, + $reloadList: { + from: '$reloadList', + required: true + }, + currentSemester: { + from: 'currentSemester', + required: true + }, + tagsEnabled: { + from: 'configStvTagsEnabled', + default: false + }, + }, + props: { + selected: Array, + studiengangKz: Number, + studiensemesterKurzbz: String + }, + emits: [ + 'update:selected', + 'filterActive' + ], + data() { + return { + tagFilterState: { + initialOptions: [], + selectedOptions: [], + }, + tagFilterLabels: { + tag: "Tag", + clear: "Clear", + connectors: { + AND: "AND", + OR: "OR", + NOT: "NOT", + }, + }, + selectedColumnValues: [], + tabulatorOptions: { + columns: StvColumns, + locale: true, + rowFormatter(row) { + if (row.getData().bnaktiv === false) { + row.getElement().classList.add('text-black','text-opacity-50','fst-italic'); + } + row.getElement().draggable = true + }, - ajaxRequestFunc: (url, config, params) => { - if( url === '' ) - { - return Promise.resolve({ data: []}); - } - /** - * NOTE(chris): Because of a bug in Tabulator - * we need to get the params from elsewhere. - * @see https://github.com/olifolkerd/tabulator/issues/4318 - */ - const apiconfig = { - ...this.tabulatorOptions.ajaxConfig, - url: this.tabulatorOptions.ajaxURL, - params: this.tabulatorOptions.ajaxParams - }; - return this.$api.call(apiconfig); - }, - ajaxResponse: (url, params, response) => { - return response?.data; - }, + ajaxRequestFunc: (url, config, params) => { + if ( url === '' ) { + return Promise.resolve({ data: []}); + } + /** + * NOTE(chris): Because of a bug in Tabulator + * we need to get the params from elsewhere. + * @see https://github.com/olifolkerd/tabulator/issues/4318 + */ + const apiconfig = { + ...this.tabulatorOptions.ajaxConfig, + url: this.tabulatorOptions.ajaxURL, + params: this.tabulatorOptions.ajaxParams + }; + return this.$api.call(apiconfig); + }, + ajaxResponse: (url, params, response) => { + return response?.data; + }, - layout: 'fitDataStretch', - layoutColumnsOnNewData: false, - height: '100%', - selectableRows: true, - selectableRowsRangeMode: 'click', - index: 'prestudent_id', - persistenceID: 'stv-list-20260223_01' - }, - tabulatorEvents: [ - { - event: 'rowSelectionChanged', - handler: this.rowSelectionChanged - }, - { - event: 'dataLoading', - handler: this.handleDataLoading - }, - { - event: 'renderComplete', - handler: this.handleRenderComplete - }, - { - event: 'dataProcessed', - handler: (data) => { - this.getAllRows() - this.autoSelectRows(data) - } - }, - { - event: 'dataLoaded', - handler: data => { - if (Array.isArray(data)) { - this.count = data.length; - this.allPrestudents = data.map(item => item.prestudent_id); - } else { - this.count = 0; - this.allPrestudents = []; - } - } - }, - { - event: 'dataFiltered', - handler: (filters, rows) => this.filteredcount = rows.length - }, - { - event: 'rowClick', - handler: this.handleRowClick // TODO(chris): this should be in the filter component - }, - { - event: 'dataTreeRowExpanded', - handler: (data) => { - this.getExpandedRows() - } - }, - { - event: 'dataTreeRowCollapsed', - handler: (data) => { - this.getExpandedRows() - } - }, - { - event: 'rowMouseDown', - handler: this.handleMouseDown - } - ], - focusObj: null, // TODO(chris): this should be in the filter component - lastSelected: null, - filter: [], - count: 0, - filteredcount: 0, - selectedcount: 0, - //tags - expanded: [], - selectedColumnValues: [], - tagEndpoint: ApiTag, - currentEndpoint: null, - headerFilterActive: false, - dragSource: [], - oldScrollUrl: '', - oldScrollLeft: 0, - oldScrollTop: 0, - allPrestudents: [] - } - }, - computed: { - countsToHTML: function() { - return this.$p.t('global/ausgewaehlt') - + ': ' + (this.selectedcount || 0) + '' - + ' | ' - + this.$p.t('global/gefiltert') - + ': ' - + '' + (this.filteredcount || 0) + '' - + ' | ' - + this.$p.t('global/gesamt') - + ': ' + (this.count || 0) + ''; - }, - selectedDragObject() { - let items = this.dragSource?.length ? this.dragSource : this.selected; + layout: 'fitDataStretch', + layoutColumnsOnNewData: false, + height: '100%', + selectableRows: true, + selectableRowsRangeMode: 'click', + index: 'prestudent_id', + persistence: { + sort: true, + columns: ["width", "visible"], + filter: false, + headerFilter: false, + group: false, + page: false, + }, + persistenceID: 'stv-list-20260223_01' + }, + tabulatorEvents: [ + { + event: 'rowSelectionChanged', + handler: this.rowSelectionChanged + }, + { + event: 'dataLoading', + handler: this.handleDataLoading + }, + { + event: 'renderComplete', + handler: this.handleRenderComplete + }, + { + event: 'dataProcessed', + handler: (data) => { + this.getAllRows() + this.autoSelectRows(data) + } + }, + { + event: 'dataLoaded', + handler: data => { + if (Array.isArray(data)) { + this.count = data.length; + this.allPrestudents = data.map(item => item.prestudent_id); + } else { + this.count = 0; + this.allPrestudents = []; + } + syncTagHeaderFilterOptions( + Array.isArray(data) ? data : [], + this.tagFilterState.initialOptions, + this.tagFilterState.selectedOptions, + ); + } + }, + { + event: 'dataFiltered', + handler: (filters, rows) => { + this.filteredcount = rows.length; + syncSelectedTagOptionsWithHeaderFilters( + filters, + this.tagFilterState.selectedOptions, + this.tagsEnabled, + ); + }, + }, + { + event: 'rowClick', + handler: this.handleRowClick // TODO(chris): this should be in the filter component + }, + { + event: 'dataTreeRowExpanded', + handler: (data) => { + this.getExpandedRows() + } + }, + { + event: 'dataTreeRowCollapsed', + handler: (data) => { + this.getExpandedRows() + } + }, + { + event: 'rowMouseDown', + handler: this.handleMouseDown + }, + { + event: "columnWidth", + handler: (column) => { + if (column.getField() !== "tags") return; - return items.map(item => { - let type, id; - if (item.uid) { - type = 'student'; - id = item.uid; - } else if (item.prestudent_id) { - type = 'prestudent'; - id = item.prestudent_id; - } else if (item.person_id) { - type = 'person'; - id = item.person_id; - } - return { - ...item, - type, - id - }; - }); - }, - downloadConfig() { - return { - csv: { - formatter: 'csv', - file: this.fileString, - options: { - delimiter: ';', - bom: true, - }, - rowRange: this.selectedcount > 0 ? "selected" : "all", - }, - }; - }, - fileString() { - let today = new Date().toLocaleDateString('en-GB') - .replace(/\//g, '_'); - return "StudentList_" + today + ".csv"; - }, - selectedPrestudents() { - if (this.selected && this.selected.length > 0) { - return this.selected.map(item => item.prestudent_id); - } else { - // fallback whole list of prestudents - return this.allPrestudents || []; - } - }, + column.getCells().forEach((cell) => { + cell.getElement().firstElementChild?.fitTags?.(); + }); + }, + }, + ], + focusObj: null, // TODO(chris): this should be in the filter component + lastSelected: null, + filter: [], + count: 0, + filteredcount: 0, + selectedcount: 0, + //tags + expanded: [], + tagEndpoint: ApiTag, + currentEndpoint: null, + headerFilterActive: false, + dragSource: [], + oldScrollUrl: '', + oldScrollLeft: 0, + oldScrollTop: 0, + allPrestudents: [] + } + }, + computed: { + countsToHTML: function() { + return this.$p.t('global/ausgewaehlt') + + ': ' + (this.selectedcount || 0) + '' + + ' | ' + + this.$p.t('global/gefiltert') + + ': ' + + '' + (this.filteredcount || 0) + '' + + ' | ' + + this.$p.t('global/gesamt') + + ': ' + (this.count || 0) + ''; + }, + selectedDragObject() { + let items = this.dragSource?.length ? this.dragSource : this.selected; - linkXLS(){ - return FHC_JS_DATA_STORAGE_OBJECT.app_root - + 'content/statistik/studentenexportextended.xls.php?' - + '&studiensemester_kurzbz=' + this.currentSemester - + '&data=' + this.selectedPrestudents.join(";"); - }, - }, - created() { - if(this.tagsEnabled) { - const coltags = { - title: 'Tags', - field: 'tags', - tooltip: false, - headerFilter: "input", - headerFilterFunc: tagHeaderFilter, - headerFilterFuncParams: {field: 'tags'}, - formatter: (cell) => tagFormatter(cell, this.$refs.tagComponent), - width: 150, - }; - this.tabulatorOptions.columns.splice(2, 0, coltags); - } - }, - methods: { - reload() { - this.$refs.table.reloadTable(); - }, - actionNewPrestudent() { - this.$refs.new.open(); - }, - rowSelectionChanged(data, rows, selected, deselected) { - this.selectedcount = data.length; + return items.map(item => { + let type, id; + if (item.uid) { + type = 'student'; + id = item.uid; + } else if (item.prestudent_id) { + type = 'prestudent'; + id = item.prestudent_id; + } else if (item.person_id) { + type = 'person'; + id = item.person_id; + } + return { + ...item, + type, + id + }; + }); + }, + downloadConfig() { + return { + csv: { + formatter: 'csv', + file: this.fileString, + options: { + delimiter: ';', + bom: true, + }, + rowRange: this.selectedcount > 0 ? "selected" : "all", + }, + }; + }, + fileString() { + let today = new Date().toLocaleDateString('en-GB') + .replace(/\//g, '_'); + return "StudentList_" + today + ".csv"; + }, + selectedPrestudents() { + if (this.selected && this.selected.length > 0) { + return this.selected.map(item => item.prestudent_id); + } else { + // fallback whole list of prestudents + return this.allPrestudents || []; + } + }, - //in case of empty selection (eg. in future or past semester of selected student without sem) - if(selected.length == 0 ) { - this.lastSelected = this.selected; - } + linkXLS(){ + return FHC_JS_DATA_STORAGE_OBJECT.app_root + + 'content/statistik/studentenexportextended.xls.php?' + + '&studiensemester_kurzbz=' + this.currentSemester + + '&data=' + this.selectedPrestudents.join(";"); + }, + }, + watch: { + "$p.user_language.value"(n, o) { + if (n !== o && o !== undefined && this.$refs.table.tableBuilt) { + this.translateTabulator(); + } + }, + "tagFilterState.selectedOptions": { + handler() { + const selectedOptions = this.tagFilterState.selectedOptions; + const combinedFilterStatement = + buildTagHeaderFilterExpression(selectedOptions); - if(selected.length > 0 || deselected.length > 0){ + setTagHeaderFilterValue( + combinedFilterStatement, + this.$refs.table.tabulator, + ); + }, + deep: true, + }, + }, + methods: { + translateTabulator() { + this.$p + .loadCategory([ + "global", + "person", + "lehre", + "ui", + "profilUpdate", + "admission", + "stv", + ]) + .then(() => { + this.updateTagFilterLabels(); - //for tags - this.selectedRows = this.$refs.table.tabulator.getSelectedRows(); - this.selectedColumnValues = this.selectedRows.filter( - row => row.getData().prestudent_id !== undefined - && row.getData().prestudent_id - ).map( - row => row.getData().prestudent_id - ); + const translations = { + uid: capitalize(this.$p.t("person/uid")), + titelpre: capitalize(this.$p.t("person/titelpre")), + nachname: capitalize(this.$p.t("person/nachname")), + vorname: capitalize(this.$p.t("person/vorname")), + wahlname: capitalize(this.$p.t("person/wahlname")), + vornamen: capitalize(this.$p.t("person/vornamen")), + titelpost: capitalize(this.$p.t("person/titelpost")), + ersatzkennzeichen: capitalize( + this.$p.t("person/ersatzkennzeichen"), + ), + gebdatum: capitalize(this.$p.t("person/geburtsdatum")), + geschlecht: capitalize(this.$p.t("person/geschlecht")), + semester_berechnet: capitalize(this.$p.t("lehre/sem")), + verband: capitalize(this.$p.t("lehre/verb")), + gruppe: capitalize(this.$p.t("lehre/grp")), + studiengang: capitalize(this.$p.t("lehre/studiengang")), + studiengang_kz: capitalize(this.$p.t("lehre/studiengang_kz")), + matrikelnr: capitalize(this.$p.t("person/personenkennzeichen")), + person_id: capitalize(this.$p.t("person/person_id")), + status: capitalize(this.$p.t("global/status")), + status_datum: capitalize(this.$p.t("profilUpdate/statusDate")), + status_bestaetigung: capitalize( + this.$p.t("global/status_bestaetigung"), + ), + mail_privat: capitalize(this.$p.t("person/email_private")), + mail_intern: capitalize(this.$p.t("person/email_intern")), + anmerkungen: capitalize(this.$p.t("stv/notes_person")), + anmerkung: capitalize(this.$p.t("stv/notes_prestudent")), + orgform_kurzbz: capitalize(this.$p.t("lehre/orgform")), + aufmerksamdurch_kurzbz: capitalize( + this.$p.t("person/aufmerksamDurch"), + ), + punkte: capitalize(this.$p.t("admission/gesamtpunkte")), + aufnahmegruppe_kurzbz: capitalize( + this.$p.t("stv/aufnahmegruppe_kurzbz"), + ), + dual: capitalize(this.$p.t("lehre/dual_short")), + matr_nr: capitalize(this.$p.t("person/matrikelnummer")), + studienplan_bezeichnung: capitalize(this.$p.t("lehre/studienplan")), + prestudent_id: capitalize(this.$p.t("ui/prestudent_id")), + priorisierung_relativ: capitalize(this.$p.t("lehre/prioritaet")), + mentor: capitalize(this.$p.t("stv/mentor")), + bnaktiv: capitalize(this.$p.t("person/aktiv")), + }; - this.$emit('update:selected', data); - } - }, - autoSelectRows(data) { - if (Array.isArray(this.lastSelected) && this.lastSelected.length){ - // NOTE(chris): reselect rows on refresh - let selected = this.lastSelected.map(el => this.$refs.table.tabulator.getRow(el.prestudent_id)) - // TODO(chris): unselect current item if it's no longer in the table? - // or maybe reselect only the last one? - selected = selected.filter(el => el); + /** NOTE(chris): + * use this approach because updateDefinition + * on the Tabulator columns is way slower and + * freezes up the GUI. + */ + // Overwrite definition for column show/hide + this.$refs.table.tabulator.getColumns().forEach((col) => { + const trans = translations[col.getField()]; + if (!trans) return; + col.getDefinition().title = trans; + }); + // Overwrite node in dom + this.$refs.table.tabulator.element + .querySelectorAll(".tabulator-col[tabulator-field]") + .forEach((el) => { + const field = el.getAttribute("tabulator-field"); + if (!translations[field]) return; - if (selected.length) - this.$refs.table.tabulator.selectRow(selected); - } else if(data && this.lastSelected === undefined) { - // NOTE(chris): select row if it's the only one (preferably only on startup) - if (data.length == 1) { - this.$refs.table.tabulator.selectRow(this.$refs.table.tabulator.getRows()); - } - } - }, - updateFilter(filter) { - this.filter = filter; - this.$emit('filterActive', filter); - this.updateUrl(); - }, - updateUrl(endpoint, first) { - this.lastSelected = first ? undefined : this.selected; + const title = el.querySelector(".tabulator-col-title"); + if (!title) return; -/* console.log('function param endpoint: ' + JSON.stringify(endpoint)); - console.log('current endpoint: ' + JSON.stringify(this.currentEndpoint));*/ + title.innerText = translations[field]; + }); + }); + }, + reload() { + this.$refs.table.reloadTable(); + }, + actionNewPrestudent() { + this.$refs.new.open(); + }, + rowSelectionChanged(data, rows, selected, deselected) { + this.selectedcount = data.length; - if( endpoint === undefined && this.currentEndpoint === null) - { - endpoint = { url: '' }; - } - else if( endpoint === undefined ) - { - endpoint = JSON.parse(JSON.stringify(this.currentEndpoint)); - } - else - { - this.currentEndpoint = JSON.parse(JSON.stringify(endpoint)); - } + //in case of empty selection (eg. in future or past semester of selected student without sem) + if (selected.length == 0 ) { + this.lastSelected = this.selected; + } - endpoint.url = endpoint.url.replace( - 'CURRENT_SEMESTER', - encodeURIComponent(this.currentSemester) - ); + if (selected.length > 0 || deselected.length > 0) { + this.lastSelected = this.selected; - const params = (endpoint?.params !== undefined) ? endpoint.params : {}; - let method = (endpoint?.method !== undefined) ? endpoint.method : 'get'; - if (this.filter.length && !endpoint.url.match(/\/search\//)) - { - params.filter = this.filter; - method = 'post'; - } + //for tags + this.selectedRows = this.$refs.table.tabulator.getSelectedRows(); + this.selectedColumnValues = this.selectedRows.filter( + row => row.getData().prestudent_id !== undefined + && row.getData().prestudent_id + ).map( + row => row.getData().prestudent_id + ); - this.tabulatorOptions.ajaxURL = endpoint.url; - this.tabulatorOptions.ajaxParams = { ...params }; - this.tabulatorOptions.ajaxConfig = {method}; + this.$emit('update:selected', data); + } + }, + autoSelectRows(data) { + if (Array.isArray(this.lastSelected) && this.lastSelected.length){ + // NOTE(chris): reselect rows on refresh + let selected = this.lastSelected.map(el => this.$refs.table.tabulator.getRow(el.prestudent_id)) + // TODO(chris): unselect current item if it's no longer in the table? + // or maybe reselect only the last one? + selected = selected.filter(el => el); - if (!this.$refs.table.tableBuilt) { - if (this.$refs.table.tabulator) { - this.$refs.table.tabulator.on("tableBuilt", () => { - this.$refs.table.tabulator.setData(endpoint.url, params, method); - }); - } - } else - this.$refs.table.tabulator.setData(endpoint.url, params, method); - }, - dragCleanup(evt) { - this.dragSource = []; - if (evt.dataTransfer.dropEffect == 'none') - return; // aborted or wrong target - - this.$reloadList(); - }, - onKeydown(e) { // TODO(chris): this should be in the filter component + if (selected.length) + this.$refs.table.tabulator.selectRow(selected); + } else if(data && this.lastSelected === undefined) { + // NOTE(chris): select row if it's the only one (preferably only on startup) + if (data.length == 1) { + this.$refs.table.tabulator.selectRow(this.$refs.table.tabulator.getRows()); + } + } + }, + updateFilter(filter) { + this.filter = filter; + this.$emit('filterActive', filter); + this.updateUrl(); + }, + updateTagFilterLabels() { + this.tagFilterLabels.clear = this.$p.t("ui/filterdelete"); + this.tagFilterLabels.tag = "Tag"; + this.tagFilterLabels.connectors.AND = this.$p + .t("ui/andCondition") + .toUpperCase(); + this.tagFilterLabels.connectors.OR = this.$p + .t("ui/orCondition") + .toUpperCase(); + this.tagFilterLabels.connectors.NOT = this.$p + .t("ui/notCondition") + .toUpperCase(); + }, + updateUrl(endpoint, first) { + this.lastSelected = first ? undefined : this.selected; - if ((e.ctrlKey || e.metaKey) && e.code === "KeyA") { - e.preventDefault(); + if (endpoint === undefined && this.currentEndpoint === null) { + endpoint = { url: '' }; + } else if (endpoint === undefined) { + endpoint = JSON.parse(JSON.stringify(this.currentEndpoint)); + } else { + this.currentEndpoint = JSON.parse(JSON.stringify(endpoint)); + } - this.$refs.table.tabulator.deselectRow(); - this.$refs.table.tabulator.selectRow(); - } + endpoint.url = endpoint.url.replace( + 'CURRENT_SEMESTER', + encodeURIComponent(this.currentSemester) + ); - if (!this.focusObj) - return; + const params = (endpoint?.params !== undefined) ? endpoint.params : {}; + let method = (endpoint?.method !== undefined) ? endpoint.method : 'get'; + if (this.filter.length && !endpoint.url.match(/\/search\//)) { + params.filter = this.filter; + method = 'post'; + } - // Ignore typing inside editable elements - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target.isContentEditable - ) - return; + this.tabulatorOptions.ajaxURL = endpoint.url; + this.tabulatorOptions.ajaxParams = { ...params }; + this.tabulatorOptions.ajaxConfig = { method }; - var next; - switch (e.code) { - case 'Enter': - case 'Space': - e.preventDefault(); - var e2 = new Event('click', e); - e2.altKey = e.altKey; - e2.ctrlKey = e.ctrlKey; - e2.shiftKey = e.shiftKey; - this.focusObj.dispatchEvent(e2); - //row.component.toggleSelect(); - break; - case 'ArrowUp': - e.preventDefault(); - next = this.focusObj.previousElementSibling; - if (next) - this.changeFocus(this.focusObj, next); - break; - case 'ArrowDown': - e.preventDefault(); - next = this.focusObj.nextElementSibling; - if (next) - this.changeFocus(this.focusObj, next); - break; - } - }, - changeFocus(a, b) { // TODO(chris): this should be in the filter component - if (b) { - b.tabIndex = 0; - this.focusObj = b; - b.focus(); - } else { - this.focusObj = null; - } - a.tabIndex = -1; - return this.focusObj; - }, - onFocus(e) { // TODO(chris): this should be in the filter component - if (!this.focusObj) { - var container, target; - if (e.target.classList.contains('tabulator-container')) { - container = e.target; - target = container.querySelector('.tabulator-row'); - } else if (e.target.classList.contains('tabulator-row')) { - container = e.target.closest('.tabulator-container'); - target = e.target; - } - if (container && target) { - this.changeFocus(container, target); - } - } - }, - handleRowClick(e, row) { // TODO(chris): this should be in the filter component - if (this.focusObj) { - let el = row.getElement(); - if (el != this.focusObj) - this.changeFocus(this.focusObj, el); - } - }, - clearSelection(){ - this.lastSelected = []; - this.$emit('update:selected',[]); - }, - //methods tags - addedTag(addedTag) - { - addTagInTable(addedTag, this.allRows, 'prestudent_id') - }, - deletedTag(deletedTag) - { - deleteTagInTable(deletedTag, this.allRows); - }, - updatedTag(updatedTag) - { - updateTagInTable(updatedTag, this.allRows) - }, - getAllRows() { - this.allRows = this.$refs.table.tabulator.getRows(); - }, - resetFilter(){ - this.$refs.listfilter.resetFilter(); - this.$refs.table.clearFilters(); - }, - handleHeaderFilter(filterActive){ - this.headerFilterActive = filterActive; - }, - handleMouseDown(e, row) - { - let data = row.getData(); - let id = data.uid ?? data.prestudent_id ?? data.person_id; + if (!this.$refs.table.tableBuilt) { + if (this.$refs.table.tabulator) { + this.$refs.table.tabulator.on("tableBuilt", () => { + this.$refs.table.tabulator.setData(endpoint.url, params, method); + }); + } + } else + this.$refs.table.tabulator.setData(endpoint.url, params, method); + }, + dragCleanup(evt) { + this.dragSource = []; + if (evt.dataTransfer.dropEffect == 'none') + return; // aborted or wrong target + + this.$reloadList(); + }, + onKeydown(e) { // TODO(chris): this should be in the filter component - const isAlreadySelected = this.selected?.some(row => (row.uid ?? row.prestudent_id ?? row.person_id) === id); + if ((e.ctrlKey || e.metaKey) && e.code === "KeyA") { + e.preventDefault(); - this.dragSource = (isAlreadySelected && this.selected?.length) ? this.selected : [data]; - }, - handleDataLoading() { - this.oldScrollLeft = this.$refs.table.tabulator.rowManager.scrollLeft; - this.oldScrollTop = this.$refs.table.tabulator.rowManager.scrollTop; - }, - handleRenderComplete() { - const table = this.$refs.table.tabulator.element.querySelector('.tabulator-tableholder'); - if(table) { - const curAjaxUrl = this.$refs.table.tabulator.getAjaxUrl(); - if(this.oldScrollUrl === curAjaxUrl) { - table.scrollLeft = this.oldScrollLeft; - table.scrollTop = this.oldScrollTop; - } else { - this.oldScrollLeft = table.scrollLeft; - this.oldScrollTop = table.scrollTop; - } - this.oldScrollUrl = this.$refs.table.tabulator.getAjaxUrl(); - } - }, - }, - // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component - // TODO(chris): filter component column chooser has no accessibilty features - template: ` -
-
- + this.$refs.table.tabulator.deselectRow(); + this.$refs.table.tabulator.selectRow(); + } - + if (!this.focusObj) + return; - + // Ignore typing inside editable elements + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target.isContentEditable + ) + return; - + - - -
- -
` + + + + + + + + + ` }; diff --git a/public/js/components/Tag/Tag.js b/public/js/components/Tag/Tag.js index 894da343e..faef12f15 100644 --- a/public/js/components/Tag/Tag.js +++ b/public/js/components/Tag/Tag.js @@ -47,7 +47,8 @@ export default { updatevon: "", response: "", start: "", - ende: "" + ende: "", + prioritaet: 100, }, mode: "create" }; @@ -94,7 +95,8 @@ export default { this.tagData.automatisiert = item.automatisiert; this.tagData.start = this.formatDateTimeDay(item.start); this.tagData.ende = this.formatDateTimeDay(item.ende); - + this.tagData.prioritaet = item.prioritaet || 100; + if (item && item.notiz_id) { this.selectedTagId = item.notiz_id; @@ -192,7 +194,8 @@ export default { updateamum: "", bearbeiter: "", response: "", - readonly: false + readonly: false, + prioritaet: 100, }; this.selectedTagId = null; this.mode = "create"; diff --git a/public/js/components/Tag/tagFormatter.js b/public/js/components/Tag/tagFormatter.js index 4040111b9..072906be8 100644 --- a/public/js/components/Tag/tagFormatter.js +++ b/public/js/components/Tag/tagFormatter.js @@ -59,7 +59,7 @@ export function idTagFormatter (id, tagData, tagComponent, typeId, semesterStart let bdone = b.done ? 1 : 0; if (adone !== bdone) return adone - bdone; - return b.id - a.id; + return a.prioritaet - b.prioritaet; }); const tagsToShow = expanded diff --git a/public/js/helpers/TagHelper.js b/public/js/helpers/TagHelper.js index 15cd4d9ef..7ae02015c 100644 --- a/public/js/helpers/TagHelper.js +++ b/public/js/helpers/TagHelper.js @@ -20,25 +20,37 @@ export function addTagInTable(addedTag, rows, matchKey, tagsKey = "tags") for (const tag of addedTag.response) { if (rowData[matchKey] !== tag[matchKey]) - continue; + return; - //avoid double inserts - if (tags.some(x => x.id === tag.id)) - continue; + let tags; + try { + let initialTags = rowData[tagsKey] || "[]"; + if (typeof initialTags === 'string') { + tags = JSON.parse(initialTags); + } else { + tags = initialTags; + } + } catch (e) { + tags = []; + } - const newTag = { - id: tag.id, - prestudent_id: tag.prestudent_id, - //add also information of addedTag - beschreibung: addedTag.beschreibung, - notiz: addedTag.notiz ?? "", - style: addedTag.style, - done: addedTag.done ?? false, - typ_kurzbz: addedTag.tag_typ_kurzbz ?? addedTag.typ_kurzbz, //here seem to be 2 variations - automatisiert: addedTag.automatisiert - }; + if (!Array.isArray(tags)) + tags = []; - tags.unshift(newTag); + if (tags.some(t => t?.id === tag.id)) + return; + + let newTag = { ...addedTag, id: tag.id }; + + tags.push(newTag); + tags.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + + if (adone !== bdone) return adone - bdone; + return a.prioritaet - b.prioritaet; + }); + rowData[tagsKey] = JSON.stringify(tags); updated = true; } @@ -69,10 +81,11 @@ export function deleteTagInTable(deletedTag, rows, tagsKeys = ['tags']) let tags = []; try { - if (typeof raw === "string") { - tags = JSON.parse(raw || "[]"); - } else if (Array.isArray(raw)) { - tags = [...raw]; + let initialTags = rowData[key] || "[]"; + if (typeof initialTags === 'string') { + tags = JSON.parse(initialTags); + } else { + tags = initialTags; } } catch (e) { tags = []; @@ -113,7 +126,12 @@ export function updateTagInTable(updatedTag, rows, fields = ['tags']) let fieldData; try { - fieldData = JSON.parse(rowData[field] || "[]"); + let initialTags = rowData[field] || "[]"; + if (typeof initialTags === 'string') { + fieldData = JSON.parse(initialTags); + } else { + fieldData = initialTags; + } } catch (e) { return; } @@ -126,6 +144,14 @@ export function updateTagInTable(updatedTag, rows, fields = ['tags']) if (index !== -1) { fieldData[index] = { ...updatedTag }; + fieldData.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bdone = b.done ? 1 : 0; + + if (adone !== bdone) return adone - bdone; + return a.prioritaet - b.prioritaet; + }); + let updatedFieldData = JSON.stringify(fieldData); if (updatedFieldData !== rowData[field]) diff --git a/public/js/tabulator/filters/extendedHeaderFilter.js b/public/js/tabulator/filters/extendedHeaderFilter.js index 35b66dc1c..8626ad4f9 100644 --- a/public/js/tabulator/filters/extendedHeaderFilter.js +++ b/public/js/tabulator/filters/extendedHeaderFilter.js @@ -1,163 +1,617 @@ -function parseFilterExpression(expression) -{ - const collections = []; +let activeTagDropdownCleanup = null; +const TAG_FILTER_CONNECTORS = { + AND: { + key: "AND", + label: "AND", + operator: "&&", + isVisible: false, + }, + OR: { + key: "OR", + label: "OR", + operator: "||", + isVisible: true, + }, + NOT: { + key: "NOT", + label: "NOT", + operator: "!", + isVisible: true, + }, +}; +const TAG_FILTER_CONNECTOR_ORDER = [ + //TAG_FILTER_CONNECTORS.AND, + TAG_FILTER_CONNECTORS.OR, + TAG_FILTER_CONNECTORS.NOT, +]; - try { - const orParts = expression.split('||').map(part => part.trim()); - - orParts.forEach(part => { - - const andParts = part.split('&&').map(p => p.trim()); - - const collection = { positives: [], negatives: [] }; - - andParts.forEach(term => { - - const comparisonMatch = term.match(/^(<=|>=|<|>|=|!=)\s*(-?\d+(?:[.,]\d+)?)$/); - - if (comparisonMatch) - { - const operator = comparisonMatch[1]; - const numberStr = comparisonMatch[2].replace(',', '.'); - const number = parseFloat(numberStr); - collection.positives.push({ type: 'comparison', operator, number }); - } - else if (term.startsWith('!')) - { - const excludeTerm = term.substring(1).trim().replace(/\*/g, '.*'); - collection.negatives.push({ type: 'regex', regex: new RegExp(excludeTerm, 'i') }); - } - else - { - const includeTerm = term.replace(/\*/g, '.*'); - collection.positives.push({ type: 'regex', regex: new RegExp(includeTerm, 'i') }); - } - }); - collections.push(collection); - }); - } catch (e) {} - return collections; +function getConnectorLabel(connector, labels) { + return labels?.connectors?.[connector.key] || connector.label; } -export function extendedHeaderFilter(headerValue, rowValue, rowData, filterParams) -{ - const fields = Array.isArray(filterParams?.field) - ? filterParams.field - : [filterParams?.field]; +export function setTagHeaderFilterValue(value, tabulator, columnField = "tags") { + tabulator?.setHeaderFilterValue(columnField, value); - if (fields.length > 1 && rowData) - { - rowValue = fields - .map(f => rowData[f] ?? '') - .filter(Boolean) - .join(' '); - } - if (typeof headerValue === 'boolean') - { - return rowValue === headerValue; - } + const input = tabulator + ?.getColumn(columnField) + ?.getElement() + ?.querySelector(".tabulator-header-filter input"); - const collections = parseFilterExpression(headerValue); - - function matchValue(value) - { - try { - return collections.some(collection => { - - let positives = collection.positives.length === 0 || collection.positives.every(condition => { - - if (condition.type === 'comparison') - { - let value = parseFloat(rowValue); - if (isNaN(value)) return false; - - switch (condition.operator) { - case '<': - return value < condition.number; - case '>': - return value > condition.number; - case '<=': - return value <= condition.number; - case '>=': - return value >= condition.number; - case '=': - return value === condition.number; - case '!=': - return value !== condition.number; - default: - return false; - } - } - else if (condition.type === 'regex') - { - return condition.regex.test(rowValue); - } - return false; - }); - - let negatives = collection.negatives.every(condition => { - return !condition.regex.test(rowValue); - }); - - return positives && negatives; - }); - } catch (e) { - - } - } - - if (matchValue(rowValue)) - return true; - - if (rowData && filterParams) - { - const childrenField = filterParams?.children || '_children'; - const field = filterParams?.field; - - const children = rowData[childrenField]; - if (Array.isArray(children)) - { - for (let child of children) - { - let childValue = child[field]; - if (extendedHeaderFilter(headerValue, childValue, child, filterParams)) - { - return true; - } - } - } - } - - return false; + if (input) { + input.value = value ?? ""; + } } -export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams) -{ - let data; +export function buildTagHeaderFilterExpression(selectedTagOptions) { + let expression = null; - try { - data = typeof rowValue === 'string' ? JSON.parse(rowValue) : rowValue; - } catch (error) { - return false; - } + selectedTagOptions.forEach(({ value, connector }) => { + const connectorDef = TAG_FILTER_CONNECTORS[connector]; + if (!connectorDef) return; - let combinedText; + if (connectorDef.key === TAG_FILTER_CONNECTORS.NOT.key) { + expression = expression + ? `${expression} ${TAG_FILTER_CONNECTORS.AND.operator} ${connectorDef.operator}${value}` + : `${connectorDef.operator}${value}`; + } else { + expression = expression + ? `${expression} ${connectorDef.operator} ${value}` + : value; + } + }); - if (Array.isArray(data)) - { - combinedText = data - .filter(item => item?.done !== true) - .map(item => `${item?.beschreibung} ${item?.notiz}`) - .join(' '); - } - else if (typeof data === 'object' && data !== null) - { - combinedText = data?.erledigt === false ? `${data?.beschreibung} ${data?.notiz}` : ''; - } - else - { - combinedText = String(data); - } + return expression; +} - return extendedHeaderFilter(headerValue, combinedText, rowData, filterParams) -} \ No newline at end of file +export function buildTagOptionsFromRows(rows, field = "tags") { + let parsedTags = [ + ...new Map( + getRawTagValuesFromRows(rows, field) + .filter((tag) => tag && tag.done !== true) + .sort((a, b) => { + return a.prioritaet - b.prioritaet; + }) + .map((tag) => { + return { + label: tag.beschreibung || tag.typ_kurzbz, + value: tag.beschreibung || tag.typ_kurzbz, + style: tag.style, + automatisiert: tag.automatisiert, + done: tag.done, + }; + }) + .map((obj) => [obj.value, obj]), + ).values(), + ]; + if (!Array.isArray(parsedTags)) return; + + return parsedTags; +} + +export function syncTagHeaderFilterOptions( + rows = [], + initialOptions = [], + selectedOptions = [], + field = "tags" +) { + const options = buildTagOptionsFromRows(rows, field) || []; + + if (Array.isArray(initialOptions)) { + initialOptions.splice(0, initialOptions.length, ...options); + } + + if (Array.isArray(selectedOptions)) { + const availableValues = new Set(options.map((option) => option.value)); + + for (let i = selectedOptions.length - 1; i >= 0; i--) { + if (!availableValues.has(selectedOptions[i].value)) { + selectedOptions.splice(i, 1); + } + } + } + + return options; +} + +export function syncSelectedTagOptionsWithHeaderFilters( + filters = [], + selectedOptions = [], + tagsEnabled = true, + field = "tags", +) { + if ( + !tagsEnabled || + !Array.isArray(selectedOptions) || + !selectedOptions.length + ) { + return false; + } + + const hasActiveTagFilter = filters.some( + (filter) => + filter.field === field && + filter.value !== "" && + filter.value !== null && + filter.value !== undefined, + ); + + if (hasActiveTagFilter) { + return false; + } + + selectedOptions.splice(0, selectedOptions.length); + + return true; +} + +export function getRawTagValuesFromRows(rows = [], field = "tags") { + let parsedTags = []; + + rows.forEach((row) => { + if (!row || (!row[field]?.length && !row._children?.length)) return; + + let newTags = row[field]?.length + ? [row[field]] + : getRawTagValuesFromRows(row._children, field); + + newTags = newTags + .map((newTag) => { + if (typeof newTag === "string") { + try { + newTag = JSON.parse(newTag); + } catch { + return null; + } + } + + return newTag; + }) + .filter((newTag) => newTag !== null); + + parsedTags = parsedTags.concat(newTags); + }); + + return parsedTags.flat(); +} + +export function customTagFilter(cell, onRendered, success, cancel, params) { + const container = document.createElement("div"); + container.style.width = "100%"; + + const input = document.createElement("input"); + input.style.width = "100%"; + input.type = "text"; + let dropdownMenu = null; + let suppressScrollCloseUntil = 0; + + Object.defineProperty(container, "value", { + get: () => input.value, + set: (value) => { + input.value = value ?? ""; + }, + }); + + const positionDropdown = () => { + if (!dropdownMenu) return; + + const rect = input.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 360), window.innerWidth - 16); + const left = Math.min( + Math.max(rect.left, 8), + window.innerWidth - width - 8, + ); + + dropdownMenu.style.position = "fixed"; + dropdownMenu.style.left = `${left}px`; + dropdownMenu.style.top = `${rect.bottom + 2}px`; + dropdownMenu.style.width = `${width}px`; + dropdownMenu.style.maxHeight = "50vh"; + dropdownMenu.style.overflowY = "auto"; + dropdownMenu.style.zIndex = "2000"; + }; + + const closeDropdown = () => { + if (dropdownMenu) { + dropdownMenu.remove(); + dropdownMenu = null; + } + + if (activeTagDropdownCleanup === closeDropdown) { + activeTagDropdownCleanup = null; + } + + window.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("resize", positionDropdown); + document.removeEventListener("mousedown", handleOutsideClick, true); + }; + + const suppressScrollClose = () => { + suppressScrollCloseUntil = Date.now() + 300; + }; + + const handleScroll = (event) => { + const target = event.target; + const isDropdownScroll = + target instanceof Node && dropdownMenu?.contains(target); + + if (isDropdownScroll || Date.now() < suppressScrollCloseUntil) { + return; + } + + closeDropdown(); + }; + + const handleOutsideClick = (event) => { + if ( + dropdownMenu?.contains(event.target) || + container.contains(event.target) + ) { + return; + } + + closeDropdown(); + }; + + const clearFilter = ({ closeDropdownAfterClear = true } = {}) => { + input.value = ""; + + if (Array.isArray(params.selectedOptions)) { + params.selectedOptions.splice(0, params.selectedOptions.length); + } + + success(""); + + if (closeDropdownAfterClear) { + closeDropdown(); + } + }; + + const openDropdown = () => { + if (activeTagDropdownCleanup) { + activeTagDropdownCleanup(); + } + + dropdownMenu = getRebuiltDropdown( + params.initialOptions, + params.selectedOptions, + params.labels, + clearFilter, + ); + dropdownMenu.addEventListener("mousedown", (event) => { + event.stopPropagation(); + }); + dropdownMenu.addEventListener("click", suppressScrollClose, true); + + document.body.appendChild(dropdownMenu); + activeTagDropdownCleanup = closeDropdown; + + positionDropdown(); + + window.addEventListener("scroll", handleScroll, true); + window.addEventListener("resize", positionDropdown); + document.addEventListener("mousedown", handleOutsideClick, true); + }; + + const stopHeaderFilterGesture = (event) => { + event.stopPropagation(); + }; + + input.addEventListener("pointerdown", stopHeaderFilterGesture); + input.addEventListener("mousedown", stopHeaderFilterGesture); + input.addEventListener("touchstart", stopHeaderFilterGesture); + input.addEventListener("click", stopHeaderFilterGesture); + input.addEventListener("input", () => { + success(input.value); + }); + input.addEventListener("change", () => { + success(input.value); + }); + input.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + + event.preventDefault(); + event.stopPropagation(); + + clearFilter({ closeDropdownAfterClear: false }); + + openDropdown(); + }); + input.addEventListener("focus", (e) => { + e.stopPropagation(); + openDropdown(); + }); + + container.appendChild(input); + + return container; +} + +function getRebuiltDropdown( + initialOptions, + selectedOptions, + labels, + clearFilter, +) { + const dropdownTable = generateDropdownTable( + initialOptions || [], + selectedOptions || [], + labels, + ); + + const menu = document.createElement("div"); + menu.className = "dropdown-menu show py-3 px-1"; + + const clearButton = document.createElement("button"); + clearButton.type = "button"; + clearButton.className = + "btn btn-link btn-sm p-0 position-absolute top-0 end-0 m-1"; + clearButton.title = labels?.clear || "Clear"; + clearButton.setAttribute("aria-label", labels?.clear || "Clear"); + + const clearIcon = document.createElement("i"); + clearIcon.className = "fa-solid fa-filter-circle-xmark"; + clearButton.appendChild(clearIcon); + + clearButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + clearFilter({ closeDropdownAfterClear: false }); + menu.querySelectorAll("input[type='checkbox']").forEach((checkbox) => { + checkbox.checked = false; + }); + }); + + menu.appendChild(clearButton); + menu.appendChild(dropdownTable); + + return menu; +} + +function generateDropdownTable(options, selectedTagOptions, labels) { + const table = document.createElement("table"); + table.className = "table table-sm mb-0"; + + // HEADER + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + + const headers = [ + labels?.tag || "Tag", + ...TAG_FILTER_CONNECTOR_ORDER.map((connector) => + getConnectorLabel(connector, labels), + ), + ]; + + headers.forEach((h, index) => { + const th = document.createElement("th"); + if (index > 0) { + th.className = "text-center"; + } + + const strong = document.createElement("strong"); + strong.textContent = h; + th.appendChild(strong); + headRow.appendChild(th); + }); + + thead.appendChild(headRow); + table.appendChild(thead); + + // BODY + const tbody = document.createElement("tbody"); + options.forEach((option) => { + const row = document.createElement("tr"); + + // LABEL + const labelTd = document.createElement("td"); + const label = document.createElement("span"); + label.className = "tag " + option.style; + label.innerText = option.label; + + if (option.automatisiert) { + label.innerHTML = " " + option.label; + label.className += " tag_auto"; + } + + labelTd.appendChild(label); + row.appendChild(labelTd); + + function makeCheckbox(connector) { + const td = document.createElement("td"); + const cb = document.createElement("input"); + + td.className = "text-center align-middle"; + + cb.type = "checkbox"; + cb.style.cursor = "pointer"; + + cb.checked = selectedTagOptions.some( + (t) => t.value === option.value && t.connector === connector.key, + ); + + cb.addEventListener("click", (event) => { + event.stopPropagation(); + + if (event.target.checked) { + selectedTagOptions.push({ + label: option.label, + value: option.value, + connector: connector.key, + }); + } else { + const idx = selectedTagOptions.findIndex( + (t) => t.value === option.value && t.connector === connector.key, + ); + + if (idx !== -1) { + selectedTagOptions.splice(idx, 1); + } + } + }); + + td.appendChild(cb); + return td; + } + + TAG_FILTER_CONNECTOR_ORDER.forEach((connector) => { + row.appendChild(makeCheckbox(connector)); + }); + + tbody.appendChild(row); + }); + + table.appendChild(tbody); + + return table; +} + +function parseFilterExpression(expression) { + const collections = []; + + try { + const orParts = expression.split("||").map((part) => part.trim()); + + orParts.forEach((part) => { + const andParts = part.split("&&").map((p) => p.trim()); + + const collection = { positives: [], negatives: [] }; + + andParts.forEach((term) => { + const comparisonMatch = term.match( + /^(<=|>=|<|>|=|!=)\s*(-?\d+(?:[.,]\d+)?)$/, + ); + + if (comparisonMatch) { + const operator = comparisonMatch[1]; + const numberStr = comparisonMatch[2].replace(",", "."); + const number = parseFloat(numberStr); + collection.positives.push({ type: "comparison", operator, number }); + } else if (term.startsWith("!")) { + const excludeTerm = term.substring(1).trim().replace(/\*/g, ".*"); + collection.negatives.push({ + type: "regex", + regex: new RegExp(excludeTerm, "i"), + }); + } else { + const includeTerm = term.replace(/\*/g, ".*"); + collection.positives.push({ + type: "regex", + regex: new RegExp(includeTerm, "i"), + }); + } + }); + collections.push(collection); + }); + } catch (e) {} + return collections; +} + +export function extendedHeaderFilter( + headerValue, + rowValue, + rowData, + filterParams, +) { + const fields = Array.isArray(filterParams?.field) + ? filterParams.field + : [filterParams?.field]; + + if (fields.length > 1 && rowData) { + rowValue = fields + .map((f) => rowData[f] ?? "") + .filter(Boolean) + .join(" "); + } + if (typeof headerValue === "boolean") { + return rowValue === headerValue; + } + + const collections = parseFilterExpression(headerValue); + + function matchValue(value) { + try { + return collections.some((collection) => { + let positives = + collection.positives.length === 0 || + collection.positives.every((condition) => { + if (condition.type === "comparison") { + let value = parseFloat(rowValue); + if (isNaN(value)) return false; + + switch (condition.operator) { + case "<": + return value < condition.number; + case ">": + return value > condition.number; + case "<=": + return value <= condition.number; + case ">=": + return value >= condition.number; + case "=": + return value === condition.number; + case "!=": + return value !== condition.number; + default: + return false; + } + } else if (condition.type === "regex") { + return condition.regex.test(rowValue); + } + return false; + }); + + let negatives = collection.negatives.every((condition) => { + return !condition.regex.test(rowValue); + }); + + return positives && negatives; + }); + } catch (e) {} + } + + if (matchValue(rowValue)) return true; + + if (rowData && filterParams) { + const childrenField = filterParams?.children || "_children"; + const field = filterParams?.field; + + const children = rowData[childrenField]; + if (Array.isArray(children)) { + for (let child of children) { + let childValue = child[field]; + if ( + extendedHeaderFilter(headerValue, childValue, child, filterParams) + ) { + return true; + } + } + } + } + + return false; +} +export function tagHeaderFilter(headerValue, rowValue, rowData, filterParams) { + let data; + + try { + data = typeof rowValue === "string" ? JSON.parse(rowValue) : rowValue; + } catch (error) { + return false; + } + + let combinedText; + + if (Array.isArray(data)) { + combinedText = data + .filter((item) => item?.done !== true) + .map((item) => `${item?.beschreibung} ${item?.notiz}`) + .join(" "); + } else if (typeof data === "object" && data !== null) { + combinedText = + data?.erledigt === false ? `${data?.beschreibung} ${data?.notiz}` : ""; + } else { + combinedText = String(data); + } + + return extendedHeaderFilter(headerValue, combinedText, rowData, filterParams); +} diff --git a/public/js/tabulator/formatter/tags.js b/public/js/tabulator/formatter/tags.js index f334bb8df..d78ed2026 100644 --- a/public/js/tabulator/formatter/tags.js +++ b/public/js/tabulator/formatter/tags.js @@ -1,90 +1,128 @@ -export function tagFormatter(cell, tagComponent) -{ - const mappedData = tagComponent.tags.map(tag => ({ - typ_kurzbz: tag.tag_typ_kurzbz, - automatisiert: tag.automatisiert - })); +export function tagFormatter(cell, tagComponent, onRendered, tagKey = "tags") { + const mappedData = tagComponent[tagKey]?.map((tag) => ({ + typ_kurzbz: tag.tag_typ_kurzbz, + automatisiert: tag.automatisiert, + })); + let tags = cell.getValue(); + if (!tags) return; - let tags = cell.getValue(); - if (!tags) return; + let container = document.createElement("div"); + container.className = "d-flex gap-1"; - let container = document.createElement('div'); - container.className = "d-flex gap-1"; + let parsedTags = []; + if (typeof tags === "string") { + parsedTags = JSON.parse(tags); + } else if (Array.isArray(tags)) { + parsedTags = tags; + } - let parsedTags = []; - if (typeof tags === 'string') { - parsedTags = JSON.parse(tags); - } else if (Array.isArray(tags)) { - parsedTags = tags; - } + let maxVisibleTags = 3; - let maxVisibleTags = 2; + const rowData = cell.getRow().getData(); + if (rowData._tagExpanded === undefined) { + rowData._tagExpanded = false; + } - const rowData = cell.getRow().getData(); - if (rowData._tagExpanded === undefined) { - rowData._tagExpanded = false; - } + const renderTags = () => { + container.innerHTML = ""; + parsedTags = parsedTags.filter((item) => item !== null); - const renderTags = () => { - container.innerHTML = ''; - parsedTags = parsedTags.filter(item => item !== null); + parsedTags.sort((a, b) => { + let adone = a.done ? 1 : 0; + let bbone = b.done ? 1 : 0; - parsedTags.sort((a, b) => { - let adone = a.done ? 1 : 0; - let bbone = b.done ? 1 : 0; + if (adone !== bbone) { + return adone - bbone; + } + return a.prioritaet - b.prioritaet; + }); + const tagsToShow = rowData._tagExpanded + ? parsedTags + : parsedTags.slice(0, maxVisibleTags); - if (adone !== bbone) - { - return adone - bbone; - } - return b.id - a.id; - }); - const tagsToShow = rowData._tagExpanded ? parsedTags : parsedTags.slice(0, maxVisibleTags); + tagsToShow.forEach((tag) => { + if (!tag) return; + let tagElement = document.createElement("span"); + tagElement.innerText = tag.beschreibung; + tagElement.title = tag.notiz; + tagElement.className = "tag " + tag.style; + if (tag.done) tagElement.className += " tag_done"; - tagsToShow.forEach(tag => { - if (!tag) return; - let tagElement = document.createElement('span'); - tagElement.innerText = tag.beschreibung; - tagElement.title = tag.notiz; - tagElement.className = "tag " + tag.style; - if (tag.done) tagElement.className += " tag_done"; - if (tag.automatisiert){ - tagElement.className += " tag_auto"; - tagElement.innerHTML = " " + tag.beschreibung; - } + const tagDef = mappedData?.find((t) => t.typ_kurzbz === tag.typ_kurzbz); - const tagDef = mappedData.find(t => t.typ_kurzbz === tag.typ_kurzbz); + if ( + (!tagDef && tag.typ_kurzbz?.includes("_auto")) || + tagDef?.automatisiert + ) { + tagElement.className += " tag_auto"; + tagElement.innerHTML = + " " + tag.beschreibung; + } - if (!tagDef && tag.typ_kurzbz?.includes("_auto") || tagDef?.automatisiert) { - tagElement.className += " tag_auto"; - tagElement.innerHTML = " " + tag.beschreibung; - } + tagElement.addEventListener("click", (event) => { + event.stopPropagation(); + event.preventDefault(); + tagComponent.editTag(tag.id); + }); - tagElement.addEventListener('click', (event) => { - event.stopPropagation(); - event.preventDefault(); - tagComponent.editTag(tag.id) - }); + container.appendChild(tagElement); + }); - container.appendChild(tagElement); - }); + if (parsedTags.length > maxVisibleTags) { + let toggle = document.createElement("button"); + toggle.innerText = + (rowData._tagExpanded ? "- " : "+ ") + + (parsedTags.length - maxVisibleTags); + toggle.className = "display_all"; + toggle.title = rowData._tagExpanded + ? "Tags ausblenden" + : "Tags einblenden"; - if (parsedTags.length > maxVisibleTags) { - let toggle = document.createElement('button'); - toggle.innerText = (rowData._tagExpanded ? '- ' : '+ ') + (parsedTags.length - maxVisibleTags); - toggle.className = "display_all"; - toggle.title = rowData._tagExpanded ? "Tags ausblenden" : "Tags einblenden"; + toggle.addEventListener("click", () => { + rowData._tagExpanded = !rowData._tagExpanded; + renderTags(); + }); - toggle.addEventListener('click', () => { - rowData._tagExpanded = !rowData._tagExpanded; - renderTags(); - }); + container.appendChild(toggle); + } + }; - container.appendChild(toggle); - } - }; + const fitTags = () => { + if (rowData._tagExpanded) { + renderTags(); + return; + } - renderTags(); - return container; -} \ No newline at end of file + let widthBuffer = 10; + maxVisibleTags = parsedTags.length; + renderTags(); + + while ( + maxVisibleTags > 0 && + container.scrollWidth > (container.clientWidth + widthBuffer) + ) { + maxVisibleTags--; + renderTags(); + } + }; + + let animationFrame = null; + container.fitTags = () => { + if (animationFrame !== null) return; + + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + fitTags(); + }); + }; + + if (onRendered) { + onRendered(() => { + container.fitTags(); + }); + } + + renderTags(); + return container; +} diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index af3ff53c8..9003ee3a5 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -61539,6 +61539,66 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'andCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'und', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'and', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'orCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'oder', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'or', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'notCondition', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'nicht', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'not', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), ); diff --git a/tests/cypress/.gitignore b/tests/cypress/.gitignore new file mode 100644 index 000000000..25e6d917c --- /dev/null +++ b/tests/cypress/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +.env.* +!.env.example +/cypress/videos +/cypress/screenshots +/results \ No newline at end of file