diff --git a/lib/Helper/Helper.php b/lib/Helper/Helper.php index 91035581e..4b713e55e 100644 --- a/lib/Helper/Helper.php +++ b/lib/Helper/Helper.php @@ -20,6 +20,8 @@ class Helper { $sql = " AND `$columnName` <= " . $filterValue['to']; } else if (isset($filterValue['exact'])) { $sql = " AND `$columnName` = " . "'{$filterValue['exact']}'"; + } else if (!empty($filterValue)) { + $sql = " AND `$columnName` IN ('" . implode("','", $filterValue) . "')"; } } else if ($filterValue === "0" || $filterValue === "1") { $sql .= " AND `$columnName` = " . $filterValue; diff --git a/public/plugins/vue/tt-components/tt-icon-select.js b/public/plugins/vue/tt-components/tt-icon-select.js index 1cb9372ed..ad14b90bb 100644 --- a/public/plugins/vue/tt-components/tt-icon-select.js +++ b/public/plugins/vue/tt-components/tt-icon-select.js @@ -1,65 +1,214 @@ Vue.component('tt-icon-select', { - props: ['options', 'label', 'value'], + props: { + options: { + type: Array, + required: true + }, + label: String, // Kept as per request, though unused in original template + value: [String, Array, Number, null], // Allowed types + multiple: { + type: Boolean, + default: false + } + }, data() { return { - selectedOption: this.options.find(option => option.value.toString() === this.value) || null, isOpen: false, observer: null, }; }, + computed: { + /** + * Internal representation of the value, ensuring it's an array in multiple mode. + * Uses computed property for cleaner prop sync than watchers. + */ + internalValue: { + get() { + return this.multiple + ? (Array.isArray(this.value) ? this.value : []) // Use value if array, else empty array + : this.value; // Use value directly in single mode + }, + set(newValue) { + // Emit the change only if it's different to prevent potential loops + if (JSON.stringify(newValue) !== JSON.stringify(this.value)) { + this.$emit('input', newValue); + } + } + }, + /** + * Determines what to display in the trigger area based on selection mode and value. + * Returns an object { icon?: string, title?: string, text?: string, showCaret?: boolean } + */ + triggerDisplay() { + if (this.multiple) { + const count = this.internalValue.length; + if (count === 0) { + return { text: 'Alle', showCaret: true }; + } else if (count === 1) { + const opt = this.options.find(o => o.value?.toString() === this.internalValue[0]?.toString()); + // Show icon if exactly one is selected and found + return opt ? { icon: opt.icon, title: opt.text } : { text: '1 selected', showCaret: true }; + } else { + // Show count for multiple items + return { text: `${count} selected`, showCaret: true }; + } + } else { + // Single selection mode: Find the corresponding option + const opt = this.options.find(o => o.value?.toString() === this.internalValue?.toString()); + // Return icon/title if found, otherwise the default "Alle" text + return opt ? { icon: opt.icon, title: opt.text } : { text: 'Alle', showCaret: true }; + } + } + }, mounted() { - if (! document.getElementById('tt-icon-select-style')) { - // Dynamically add CSS to disable default dropdown caret + // Add minimal CSS dynamically if not present + if (!document.getElementById('tt-icon-select-style')) { const style = document.createElement('style'); style.id = 'tt-icon-select-style'; - style.innerHTML = `.tt-select .dropdown-toggle::after { display: none; }`; - document.body.appendChild(style); + style.innerHTML = ` + .tt-select .dropdown-toggle::after { display: none; } + /* Minimal style for selected items in dropdown */ + .tt-select .dropdown-item:hover { background-color: #f8f9fa; } /* Subtle hover */ + .tt-select .dropdown-item.active { background-color: #e9ecef; font-weight: 500; } /* Subtle active */ + `; + document.head.appendChild(style); // Append to head } - document.addEventListener('click', this.handleClick); + // Global click listener to close dropdown + document.addEventListener('click', this.handleGlobalClick); - this.observer = new ResizeObserver(this.calculateOffset.bind(this)); - this.observer.observe(this.$refs.select); - this.calculateOffset(); + // Resize observer for positioning (references the container div 'select') + if (this.$refs.select) { + this.observer = new ResizeObserver(this.calculateOffset); + this.observer.observe(this.$refs.select); + // Ensure initial calculation happens after mount + this.$nextTick(this.calculateOffset); + } }, beforeDestroy() { - document.removeEventListener('click', this.handleClick); - this.observer.disconnect(); - }, - watch: { - value(val) { - this.selectedOption = this.options.find(option => option.value.toString() === val) || null; + // Clean up global listener and observer + document.removeEventListener('click', this.handleGlobalClick); + if (this.observer) { + this.observer.disconnect(); } }, methods: { + /** + * Toggles the dropdown visibility. + */ + toggleDropdown() { + this.isOpen = !this.isOpen; + if (this.isOpen) { + // Recalculate offset when opening + this.$nextTick(this.calculateOffset); + } + }, + /** + * Handles selection of an option from the dropdown. + */ selectOption(option) { - this.selectedOption = option; - this.$emit('input', option ? option.value.toString() : ''); - this.isOpen = false; + const selectedVal = option ? option.value : ''; // `null` represents the "Alle" option + + if (this.multiple) { + let currentValues = [...this.internalValue]; // Work with a copy + const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString()); + + if (selectedVal === null) { // "Alle" clears selection in multi-mode + currentValues = []; + this.isOpen = false; // Close after clearing + } else if (index > -1) { // Item exists, remove it + currentValues.splice(index, 1); + // Keep open to allow more selections/deselections + } else { // Item doesn't exist, add it + currentValues.push(selectedVal); + // Keep open + } + this.internalValue = currentValues; // Update via computed setter + } else { + // Single selection mode + this.internalValue = selectedVal; // Set the value directly + this.isOpen = false; // Close dropdown after selection + } }, + /** + * Checks if a given option is currently selected. + * Used for applying the 'active' class. + */ + isSelected(option) { + const checkVal = option ? option.value : null; + if (this.multiple) { + // Check if the value exists in the internal array + return this.internalValue.some(v => v?.toString() === checkVal?.toString()); + } else { + // Simple comparison for single mode + return this.internalValue?.toString() === checkVal?.toString(); + } + }, + /** + * Calculates the dropdown menu's left offset to center it. + * Based on original logic using the ref="select" container width. + */ calculateOffset() { - const offset = (this.$refs.select.offsetWidth - 64.41) / 2; - this.$refs.select.querySelector('.dropdown-menu').style.left = `${offset}px`; - }, - handleClick() { - this.isOpen = this.$refs.selectedIcon.contains(event.target) ? !this.isOpen : false; + // Ensure refs are available + if (this.$refs.select && this.$refs.dropdownMenu) { + // Original calculation: (container width - approx menu item width) / 2 + // Let's refine slightly to use actual menu width if available + const containerWidth = this.$refs.select.offsetWidth; + const menuWidth = this.$refs.dropdownMenu.offsetWidth; + // Adjust if needed, default uses original fixed width logic approx + const offset = menuWidth > 0 + ? (containerWidth - menuWidth) / 2 + : (containerWidth - 64.41) / 2; // Fallback to original approx offset + this.$refs.dropdownMenu.style.left = `${offset}px`; + } }, + /** + * Closes the dropdown if a click occurs outside the component. + */ + handleGlobalClick(event) { + if (this.$el && !this.$el.contains(event.target)) { + this.isOpen = false; + } + } }, template: ` -