From 221d810f3f195d12c554da091bf7bc19e89709a4 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 8 Oct 2026 18:01:39 +0200 Subject: [PATCH] =?UTF-8?q?raumsuche=20basic=20filter=20by=20reservieren?= =?UTF-8?q?=20&=20aktiv=20=3D=20true;=20added=20erweiterte=20filter=20and?= =?UTF-8?q?=20columns=20for=20m=C2=B2,=20stockwerk=20and=20geb=C3=A4udetei?= =?UTF-8?q?l;=20added=20the=20standort=20category=20"Ohne=20Standort"=20fo?= =?UTF-8?q?r=20virtual=20ones;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/api/frontend/v1/Ort.php | 112 +++++- .../views/CisRouterView/CisRouterView.php | 1 + public/css/components/inViewTooltip.css | 81 ++++ public/js/api/factory/ort.js | 11 +- .../js/components/Cis/Raumsuche/Raumsuche.js | 167 +++++++-- public/js/components/InViewHelp.js | 34 ++ public/js/directives/inViewTooltip.js | 352 ++++++++++++++++++ system/phrasesupdate.php | 198 ++++++++++ 8 files changed, 916 insertions(+), 40 deletions(-) create mode 100644 public/css/components/inViewTooltip.css create mode 100644 public/js/components/InViewHelp.js create mode 100644 public/js/directives/inViewTooltip.js diff --git a/application/controllers/api/frontend/v1/Ort.php b/application/controllers/api/frontend/v1/Ort.php index 47e1f3b7b..3a3d053c1 100644 --- a/application/controllers/api/frontend/v1/Ort.php +++ b/application/controllers/api/frontend/v1/Ort.php @@ -25,7 +25,19 @@ if (! defined('BASEPATH')) exit('No direct script access allowed'); */ class Ort extends FHCAPI_Controller { - + // advanced filters of getRooms: tbl_ort column => filter type, the same list as in Raumsuche.js. + // text: contains, case-insensitive; select: equals; range: _min, _max; flag: true; exists: not null + const ADVANCED_FILTERS = [ + 'ort_kurzbz' => 'text', + 'bezeichnung' => 'text', + 'planbezeichnung' => 'text', + 'stockwerk' => 'range', + 'gebteil' => 'select', + 'm2' => 'range', + 'lehre' => 'flag', + 'content_id' => 'exists' + ]; + /** * Object initialization */ @@ -37,7 +49,8 @@ class Ort extends FHCAPI_Controller 'getOrtKurzbzContent' => self::PERM_LOGGED, 'getRooms' => self::PERM_LOGGED, 'getTypes' => self::PERM_LOGGED, - 'getStandorte' => self::PERM_LOGGED + 'getStandorte' => self::PERM_LOGGED, + 'getAdvancedFilterOptions' => self::PERM_LOGGED ]); $this->load->model('ressource/Ort_model', 'OrtModel'); @@ -57,7 +70,17 @@ class Ort extends FHCAPI_Controller $this->form_validation->set_rules('datum','Datum','required'); $this->form_validation->set_rules('von','Uhrzeit Von','required|regex_match[/^[0-9]{2}:[0-9]{2}$/]'); $this->form_validation->set_rules('bis','Uhrzeit Bis','required|regex_match[/^[0-9]{2}:[0-9]{2}$/]'); - $this->form_validation->set_rules('standort_id','Standort','integer'); + $this->form_validation->set_rules('standort_id','Standort','regex_match[/^([0-9]+|none)$/]'); + foreach (self::ADVANCED_FILTERS as $column => $type) { + if ($type == 'range') { + $this->form_validation->set_rules($column.'_min', $column, 'numeric'); + $this->form_validation->set_rules($column.'_max', $column, 'numeric'); + } elseif ($type == 'flag' || $type == 'exists') { + $this->form_validation->set_rules($column, $column, 'in_list[true]'); + } else { + $this->form_validation->set_rules($column, $column, 'max_length[255]'); + } + } if($this->form_validation->run() == FALSE) { $this->terminateWithValidationErrors($this->form_validation->error_array()); } @@ -78,29 +101,33 @@ class Ort extends FHCAPI_Controller $bisStunde = getData($this->StundeModel->getStundeForTime($bis))[0]->stunde; $params = array(); - // a leading _ in ort_kurzbz or planbezeichnung marks a dummy room $qry = "SELECT DISTINCT tbl_ort.*, tbl_standort.bezeichnung AS standort FROM public.tbl_ort JOIN public.tbl_ortraumtyp USING(ort_kurzbz) LEFT JOIN public.tbl_standort USING(standort_id) - WHERE aktiv AND lehre AND left(ort_kurzbz, 1) <> '_' - AND (planbezeichnung IS NULL OR left(planbezeichnung, 1) <> '_')"; + WHERE aktiv AND reservieren"; if($typ) { $params[] = $typ; - $qry.= "AND raumtyp_kurzbz = ?"; + $qry.= " AND raumtyp_kurzbz = ?"; } - if($standort_id) { + if($standort_id === 'none') { // rooms without a Standort + $qry.= " AND standort_id IS NULL"; + } elseif($standort_id) { $params[] = $standort_id; $qry.= " AND standort_id = ?"; + } else { + $qry.= " AND standort_id IS NOT NULL"; } + $qry.= $this->advancedFilterSql($params); + if(!$isMitarbeiter) { // students are only allowed to get a subset defined by config $qry.= ' AND raumtyp_kurzbz IN ?'; $params[] = $this->config->item('roomtypes_student'); $this->addMeta('config', $this->config->item('roomtypes_student')); } - $qry.= "AND (max_person>= ? OR max_person is null)"; + $qry.= " AND (max_person>= ? OR max_person is null)"; $params[] = $personenanzahl; $qry.=" AND ort_kurzbz NOT IN @@ -145,8 +172,7 @@ class Ort extends FHCAPI_Controller { $qry = "SELECT DISTINCT standort_id, tbl_standort.bezeichnung FROM public.tbl_ort JOIN public.tbl_standort USING(standort_id) - WHERE aktiv AND lehre AND left(ort_kurzbz, 1) <> '_' - AND (planbezeichnung IS NULL OR left(planbezeichnung, 1) <> '_') + WHERE aktiv AND reservieren ORDER BY tbl_standort.bezeichnung"; $result = $this->OrtModel->execReadOnlyQuery($qry); @@ -154,6 +180,26 @@ class Ort extends FHCAPI_Controller $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); } + /** + * Values of the select filters: column => distinct values of the rooms that pass the base filter of getRooms + */ + public function getAdvancedFilterOptions() + { + $options = []; + foreach (self::ADVANCED_FILTERS as $column => $type) { + if ($type != 'select') + continue; + + $result = $this->OrtModel->execReadOnlyQuery("SELECT DISTINCT ".$column." AS value + FROM public.tbl_ort + WHERE aktiv AND reservieren AND ".$column." <> '' + ORDER BY ".$column); + $options[$column] = array_column($this->getDataOrTerminateWithError($result), 'value'); + } + + $this->terminateWithSuccess($options); + } + /** * Gets a JSON body via HTTP POST and provides the parameters */ @@ -201,5 +247,49 @@ class Ort extends FHCAPI_Controller $this->terminateWithSuccess($content); } + + //------------------------------------------------------------------------------------------------------------------ + // Private methods + + /** + * SQL conditions of the advanced filters in the GET parameters, adds their values to $params + */ + private function advancedFilterSql(&$params) + { + // no xss_clean: the values are bound, and it could change a select value so that it no longer equals the column + $sql = ''; + foreach (self::ADVANCED_FILTERS as $column => $type) { + if ($type == 'range') { + $min = $this->input->get($column.'_min'); + $max = $this->input->get($column.'_max'); + if ($min !== null && $min !== '') { + $sql.= " AND tbl_ort.".$column." >= ?::numeric"; + $params[] = $min; + } + if ($max !== null && $max !== '') { + $sql.= " AND tbl_ort.".$column." <= ?::numeric"; + $params[] = $max; + } + continue; + } + + $value = $this->input->get($column); + if ($value === null || $value === '') + continue; + + if ($type == 'flag') { + $sql.= " AND tbl_ort.".$column; + } elseif ($type == 'exists') { + $sql.= " AND tbl_ort.".$column." IS NOT NULL"; + } elseif ($type == 'select') { + $sql.= " AND tbl_ort.".$column." = ?"; + $params[] = $value; + } else { + $sql.= " AND strpos(lower(tbl_ort.".$column."), lower(?)) > 0"; + $params[] = $value; + } + } + return $sql; + } } diff --git a/application/views/CisRouterView/CisRouterView.php b/application/views/CisRouterView/CisRouterView.php index c51ca40c3..dbb781d9e 100644 --- a/application/views/CisRouterView/CisRouterView.php +++ b/application/views/CisRouterView/CisRouterView.php @@ -21,6 +21,7 @@ $includesArray = array( 'public/css/components/FilterComponent.css', 'public/css/components/Profil.css', 'public/css/components/FormUnderline.css', + 'public/css/components/inViewTooltip.css', 'public/css/components/abgabetool/abgabe.css', 'public/css/Cis4/Cms.css', 'public/css/Cis4/Studium.css' diff --git a/public/css/components/inViewTooltip.css b/public/css/components/inViewTooltip.css new file mode 100644 index 000000000..600d59302 --- /dev/null +++ b/public/css/components/inViewTooltip.css @@ -0,0 +1,81 @@ +/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height + inside the viewport, so neither a scrolling container nor a modal body clips it */ +.fhc-in-view-tooltip { + --fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd); + position: fixed; + z-index: 1080; + display: flex; + flex-direction: column; + width: max-content; + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.375rem; + background-color: var(--bs-body-bg, #fff); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + color: var(--bs-body-color, #212529); + font-size: 0.875rem; + font-weight: normal; + line-height: 1.5; + text-align: left; +} + +/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */ +html.dark .fhc-in-view-tooltip { + --fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe); +} + +/* the body scrolls, so the progress border stays on the panel edge */ +.fhc-in-view-tooltip-body { + min-height: 0; + overflow-y: auto; + padding: 0.75rem 1rem; + white-space: pre-line; /* the text keeps its line breaks */ +} + +/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks. + The 2 px stroke covers the 1 px border and 1 px inside it */ +.fhc-in-view-tooltip-progress { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.fhc-in-view-tooltip-progress rect { + fill: none; + stroke: var(--fhc-in-view-tooltip-progress); + stroke-width: 2; + stroke-dasharray: var(--fhc-in-view-tooltip-perimeter); + stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter); +} + +.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect { + animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards; +} + +/* a click during the hover timer fills the rest of the border quickly */ +.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect { + stroke-dashoffset: 0; + transition: stroke-dashoffset 0.15s linear; +} + +@keyframes fhc-in-view-tooltip-hold { + to { + stroke-dashoffset: 0; + } +} + +/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue + modal header. A class like text-muted overrides it */ +.fhc-in-view-help-btn, +.fhc-in-view-help-btn:hover, +.fhc-in-view-help-btn:focus-visible, +.btn.fhc-in-view-help-btn:active { + color: inherit; +} + +.fhc-in-view-help-btn { + line-height: 1; + text-decoration: none; +} diff --git a/public/js/api/factory/ort.js b/public/js/api/factory/ort.js index 6d2ebfb3b..ecf04ab9c 100644 --- a/public/js/api/factory/ort.js +++ b/public/js/api/factory/ort.js @@ -23,11 +23,11 @@ export default { params: { ort_kurzbz: ort_kurbz } }; }, - getRooms(datum, von, bis, typ, personenanzahl = 0, standort_id = null) { + getRooms(datum, von, bis, typ, personenanzahl = 0, standort_id = null, advancedFilters = {}) { return { method: 'get', url: '/api/frontend/v1/Ort/getRooms', - params: { datum, von, bis, typ, personenanzahl, standort_id } + params: { datum, von, bis, typ, personenanzahl, standort_id, ...advancedFilters } }; }, getRoomTypes() { @@ -43,5 +43,12 @@ export default { url: '/api/frontend/v1/Ort/getStandorte', params: { } }; + }, + getAdvancedFilterOptions() { + return { + method: 'get', + url: '/api/frontend/v1/Ort/getAdvancedFilterOptions', + params: { } + }; } }; \ No newline at end of file diff --git a/public/js/components/Cis/Raumsuche/Raumsuche.js b/public/js/components/Cis/Raumsuche/Raumsuche.js index 1c4187dd3..554005f4f 100644 --- a/public/js/components/Cis/Raumsuche/Raumsuche.js +++ b/public/js/components/Cis/Raumsuche/Raumsuche.js @@ -1,11 +1,37 @@ import {CoreFilterCmpt} from "../../../components/filter/Filter.js"; import VueDatePicker from '../../vueDatepicker.js.php'; import ApiOrt from '../../../api/factory/ort.js' +import InViewHelp from '../../InViewHelp.js'; + +// advanced filters: tbl_ort column, filter type and label, the same list as Ort::ADVANCED_FILTERS +const ADVANCED_FILTERS = [ + { column: 'lehre', type: 'flag', label: 'rauminfo/nurLehrraeume', checked: true }, + { column: 'content_id', type: 'exists', label: 'rauminfo/nurMitInfoseite' }, + { column: 'ort_kurzbz', type: 'text', label: 'rauminfo/raum_kurzbz' }, + { column: 'bezeichnung', type: 'text', label: 'global/bezeichnung' }, + { column: 'planbezeichnung', type: 'text', label: 'rauminfo/raumnummer' }, + { column: 'stockwerk', type: 'range', label: 'rauminfo/stockwerk' }, + { column: 'gebteil', type: 'select', label: 'rauminfo/gebaeudeteil' }, + { column: 'm2', type: 'range', label: 'rauminfo/flaeche' } +]; + +// the GET parameters of the advanced filters with their default values +const ADVANCED_DEFAULTS = {}; +for (const { column, type, checked } of ADVANCED_FILTERS) { + if (type === 'range') { + ADVANCED_DEFAULTS[column + '_min'] = ''; + ADVANCED_DEFAULTS[column + '_max'] = ''; + } else { + ADVANCED_DEFAULTS[column] = type === 'flag' || type === 'exists' ? !!checked : ''; + } +} + export const Raumsuche = { name: "Raumsuche", components: { VueDatePicker, CoreFilterCmpt, + InViewHelp, InputNumber: primevue.inputnumber, }, inject: ["isMobile"], @@ -17,14 +43,16 @@ export const Raumsuche = { tableBuiltResolve: null, tableBuiltPromise: null, roomtypes: null, - defaultType: { - raumtyp_kurzbz: '', - beschreibung: Vue.computed(() => this.$p.t('global/alle')) - }, anzahl: 1, selectedType: null, standorte: null, standort_id: null, + roomsRequest: 0, + loading: false, + advancedFilterFields: ADVANCED_FILTERS, + advancedFilters: { ...ADVANCED_DEFAULTS }, + advancedFilterOptions: {}, + showAdvancedFilters: false, datum: new Date(), von: Vue.ref({ hours: new Date().getHours(), @@ -58,7 +86,11 @@ export const Raumsuche = { {title: Vue.computed(() => this.$p.t('global/bezeichnung')), field: 'bezeichnung', widthGrow: 2}, {title: Vue.computed(() => this.$p.t('rauminfo/raumnummer')), field: 'nummer', widthGrow: 1}, {title: Vue.computed(() => this.$p.t('rauminfo/standort')), field: 'standort', widthGrow: 1}, + {title: Vue.computed(() => this.$p.t('rauminfo/gebaeudeteil')), field: 'gebteil', widthGrow: 1, visible: false}, + {title: Vue.computed(() => this.$p.t('rauminfo/stockwerk')), field: 'stockwerk', sorter: 'number', widthGrow: 1, visible: false}, {title: Vue.computed(() => this.$p.t('rauminfo/personcap')), field: 'personen', widthGrow: 1}, + {title: Vue.computed(() => this.$p.t('rauminfo/flaeche')), field: 'm2', sorter: 'number', widthGrow: 1, visible: false}, + {title: Vue.computed(() => this.$p.t('rauminfo/lehrraum')), field: 'lehre', formatter: 'tickCross', hozAlign: 'center', widthGrow: 1, visible: false}, {title: Vue.computed(() => this.$p.t('rauminfo/rauminfo')), field: 'linkInfo', formatter: this.linkFormatter, widthGrow: 1}, {title: Vue.computed(() => this.$p.t('rauminfo/roomReservations')), @@ -77,8 +109,21 @@ export const Raumsuche = { computed: { isDarkMode(){ return this.$theme.theme_name.value == 'dark'; + }, + // shown on the toggle, the panel can be closed while its filters are active + activeAdvancedFilters() { + return Object.keys(ADVANCED_DEFAULTS).filter(key => this.advancedFilters[key] !== ADVANCED_DEFAULTS[key]).length } }, + watch: { + datum: 'loadRooms', + von: 'loadRooms', + bis: 'loadRooms', + selectedType: 'loadRooms', + standort_id: 'loadRooms', + anzahl: 'loadRooms', + advancedFilters: { handler: 'loadRooms', deep: true } + }, methods: { tableResolve(resolve) { this.tableBuiltResolve = resolve @@ -119,7 +164,11 @@ export const Raumsuche = { bezeichnung: room.bezeichnung.replace('&', '&'), nummer: room.planbezeichnung, standort: room.standort, + gebteil: room.gebteil, + stockwerk: room.stockwerk, personen: room.max_person, + m2: room.m2, + lehre: room.lehre, linkInfo: room.content_id ? this.roomInfoLink(room) : null, linkRes: this.roomPlanLink(room) @@ -135,7 +184,6 @@ export const Raumsuche = { res?.data?.forEach(type => { type.beschreibung = type.beschreibung.replace('&', '&') }) - this.selectedType = this.defaultType this.roomtypes = res?.data ?? [] }) }, @@ -145,17 +193,31 @@ export const Raumsuche = { this.standorte = res?.data ?? [] }) }, - loadRooms() { - this.$api.call(ApiOrt.getRooms(this.datum.toISOString(), this.getTimeString(this.von), this.getTimeString(this.bis), this.selectedType?.raumtyp_kurzbz ?? '', this.anzahl, this.standort_id)) + loadAdvancedFilterOptions() { + this.$api.call(ApiOrt.getAdvancedFilterOptions()) .then(res => { - if(res?.data?.retval) this.setupData(res.data.retval) - }) + this.advancedFilterOptions = res?.data ?? {} + }) + }, + loadRooms() { + // only the latest request may fill the table, older answers can arrive later + const request = ++this.roomsRequest + this.loading = true + // empty and unchecked filters stay out of the request + const advancedFilters = Object.fromEntries(Object.entries(this.advancedFilters).filter(([, value]) => value !== '' && value !== false)) + this.$api.call(ApiOrt.getRooms(this.datum.toISOString(), this.getTimeString(this.von), this.getTimeString(this.bis), this.selectedType?.raumtyp_kurzbz ?? '', this.anzahl, this.standort_id, advancedFilters)) + .then(res => { + if(request === this.roomsRequest && res?.data?.retval) this.setupData(res.data.retval) + }) + .finally(() => { + if(request === this.roomsRequest) this.loading = false + }) }, handleUuidDefined(uuid) { this.tabulatorUuid = uuid }, - search(){ - this.loadRooms() + resetAdvancedFilters() { + this.advancedFilters = { ...ADVANCED_DEFAULTS } }, setRoute(val) { // TODO: router push @@ -178,6 +240,7 @@ export const Raumsuche = { this.loadRoomTypes() this.loadStandorte() + this.loadAdvancedFilterOptions() this.loadRooms() const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : '' @@ -193,14 +256,17 @@ export const Raumsuche = { } }, created() { - this.phrasenPromise = this.$p.loadCategory(['rauminfo', 'global']) + this.phrasenPromise = this.$p.loadCategory(['rauminfo', 'global', 'ui', 'filter']) this.phrasenPromise.then(()=> {this.phrasenResolved = true}) }, mounted() { this.setupMounted() }, template: ` -

{{$p.t('rauminfo/roomSearch')}}

+
+

{{$p.t('rauminfo/roomSearch')}}

+ +

@@ -249,7 +315,7 @@ export const Raumsuche = {
@@ -257,6 +323,7 @@ export const Raumsuche = {
@@ -269,22 +336,68 @@ export const Raumsuche = { :style="{'width': '100%'}" />
-
- -
- +
+ +
+
+
+
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
`, }; diff --git a/public/js/components/InViewHelp.js b/public/js/components/InViewHelp.js new file mode 100644 index 000000000..2f2586828 --- /dev/null +++ b/public/js/components/InViewHelp.js @@ -0,0 +1,34 @@ +import InViewTooltip from '../directives/inViewTooltip.js'; + +/** + * ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the + * screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there. + * + * Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click, + * a tap toggles it on touch. sticky: false makes it a plain hover tooltip. + * + * The view needs public/css/components/inViewTooltip.css. + * + * Usage: + */ +export default { + name: 'InViewHelp', + directives: { + tooltip: InViewTooltip + }, + props: { + text: { type: String, default: '' }, + sticky: { type: Boolean, default: true }, + buttonClass: { type: [String, Array, Object], default: '' } + }, + template: /*html*/ ` + ` +}; diff --git a/public/js/directives/inViewTooltip.js b/public/js/directives/inViewTooltip.js new file mode 100644 index 000000000..023d15755 --- /dev/null +++ b/public/js/directives/inViewTooltip.js @@ -0,0 +1,352 @@ +/** + * v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone. + * + * - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when + * it fits only there or that side has more space. It grows away from the nearer viewport side. + * - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge. + * - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it. + * - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it. + * + * Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not. + * - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end. + * A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel. + * - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal. + * + * The view needs public/css/components/inViewTooltip.css. + * + * Usage examples: + * + * 1) Directive, registered for the app or the component: + * app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip } + *