diff --git a/application/controllers/api/frontend/v1/Ort.php b/application/controllers/api/frontend/v1/Ort.php index a208e4c0d..c921c0c4f 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,9 @@ class Ort extends FHCAPI_Controller 'getOrtKurzbzContent' => self::PERM_LOGGED, 'getRooms' => self::PERM_LOGGED, 'getTypes' => self::PERM_LOGGED, - 'getRoomInfo' => self::PERM_LOGGED + 'getRoomInfo' => self::PERM_LOGGED, + 'getStandorte' => self::PERM_LOGGED, + 'getAdvancedFilterOptions' => self::PERM_LOGGED ]); $this->load->model('ressource/Ort_model', 'OrtModel'); @@ -57,6 +71,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','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()); } @@ -66,6 +91,7 @@ class Ort extends FHCAPI_Controller $bis = $this->input->get('bis', TRUE); $typ = $this->input->get('typ', TRUE); $personenanzahl = $this->input->get('personenanzahl', TRUE); + $standort_id = $this->input->get('standort_id', TRUE); $this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel'); @@ -78,21 +104,33 @@ class Ort extends FHCAPI_Controller $this->load->model('ressource/Ort_model', 'OrtModel'); $params = array(); - $qry = "SELECT DISTINCT tbl_ort.* + $qry = "SELECT DISTINCT tbl_ort.*, tbl_standort.bezeichnung AS standort FROM public.tbl_ort JOIN public.tbl_ortraumtyp USING(ort_kurzbz) - WHERE aktiv AND lehre AND ort_kurzbz NOT LIKE '\\\\_%'"; + LEFT JOIN public.tbl_standort USING(standort_id) + WHERE aktiv AND reservieren"; if($typ) { $params[] = $typ; - $qry.= "AND raumtyp_kurzbz = ?"; + $qry.= " AND raumtyp_kurzbz = ?"; } + 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 @@ -130,6 +168,41 @@ class Ort extends FHCAPI_Controller $this->terminateWithSuccess(getData($result)); } + /** + * Standorte of the rooms that pass the base filter of getRooms + */ + public function getStandorte() + { + $qry = "SELECT DISTINCT standort_id, tbl_standort.bezeichnung + FROM public.tbl_ort JOIN public.tbl_standort USING(standort_id) + WHERE aktiv AND reservieren + ORDER BY tbl_standort.bezeichnung"; + + $result = $this->OrtModel->execReadOnlyQuery($qry); + + $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 */ @@ -188,5 +261,49 @@ class Ort extends FHCAPI_Controller $this->terminateWithSuccess($room); } + + //------------------------------------------------------------------------------------------------------------------ + // 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 1257ea563..529dc1f08 100644 --- a/application/views/CisRouterView/CisRouterView.php +++ b/application/views/CisRouterView/CisRouterView.php @@ -22,6 +22,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 dce61279d..ac948a3f3 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) { + 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 }, + method: 'get', + url: '/api/frontend/v1/Ort/getRooms', + params: { datum, von, bis, typ, personenanzahl, standort_id, ...advancedFilters } }; }, getRoomTypes() { @@ -46,4 +46,18 @@ export default { }, }; }, + getStandorte() { + return { + method: 'get', + url: '/api/frontend/v1/Ort/getStandorte', + params: { } + }; + }, + getAdvancedFilterOptions() { + return { + method: 'get', + url: '/api/frontend/v1/Ort/getAdvancedFilterOptions', + params: { } + }; + } }; diff --git a/public/js/components/Cis/Raumsuche/Raumsuche.js b/public/js/components/Cis/Raumsuche/Raumsuche.js index ecc6b9003..7c5509a7f 100644 --- a/public/js/components/Cis/Raumsuche/Raumsuche.js +++ b/public/js/components/Cis/Raumsuche/Raumsuche.js @@ -2,12 +2,39 @@ import {CoreFilterCmpt} from "../../../components/filter/Filter.js"; import FormInput from "../../Form/Input.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, FormInput, + InViewHelp, + InputNumber: primevue.inputnumber, }, inject: ["isMobile"], data() { @@ -18,13 +45,17 @@ export const Raumsuche = { tableBuiltResolve: null, tableBuiltPromise: null, roomtypes: null, - defaultType: { - raumtyp_kurzbz: '', - beschreibung: Vue.computed(() => this.$p.t('global/alle')) - }, anzahl: 1, anzahlInput: '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(), @@ -57,7 +88,12 @@ export const Raumsuche = { {title: Vue.computed(() => this.$p.t('rauminfo/raum_kurzbz')), field: 'ort_kurzbz', widthGrow: 1}, {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')), @@ -76,8 +112,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 @@ -117,7 +166,12 @@ export const Raumsuche = { ort_kurzbz: room.ort_kurzbz, 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) @@ -133,15 +187,34 @@ export const Raumsuche = { res?.data?.forEach(type => { type.beschreibung = type.beschreibung?.replace('&', '&') }) - this.selectedType = this.defaultType this.roomtypes = 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)) + loadStandorte() { + this.$api.call(ApiOrt.getStandorte()) .then(res => { - if(res?.data?.retval) this.setupData(res.data.retval) - }) + this.standorte = res?.data ?? [] + }) + }, + loadAdvancedFilterOptions() { + this.$api.call(ApiOrt.getAdvancedFilterOptions()) + .then(res => { + 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 @@ -160,6 +233,9 @@ export const Raumsuche = { this.normalizeAnzahl() this.loadRooms() }, + resetAdvancedFilters() { + this.advancedFilters = { ...ADVANCED_DEFAULTS } + }, setRoute(val) { // TODO: router push }, @@ -180,6 +256,8 @@ export const Raumsuche = { await this.tableBuiltPromise this.loadRoomTypes() + this.loadStandorte() + this.loadAdvancedFilterOptions() this.loadRooms() const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : '' @@ -195,14 +273,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')}}

+ +

@@ -248,16 +329,24 @@ export const Raumsuche = {
-
+
+ +
+ +
-
+
{{ $p.t('rauminfo/minCapacity') }}:
-
- -
- +
+ +
+
+
+
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
`, }; 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 } + *