Rml workorder/v2 need improvements

This commit is contained in:
Luca Haid
2025-08-05 07:05:33 +00:00
parent cccc00f434
commit 918ca3aafa
16 changed files with 1534 additions and 613 deletions
@@ -1,3 +1,4 @@
/* tt-file-gallery.css */
.tt-file-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -13,11 +14,18 @@
cursor: pointer;
}
.tt-file-gallery-thumbnail {
.tt-file-gallery-thumbnail-wrapper {
position: relative;
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 0.25rem;
overflow: hidden; /* Hide the scaled part of the image */
}
.tt-file-gallery-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
border: 1px solid #dee2e6;
transition: transform 0.2s;
}
@@ -30,8 +38,8 @@
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 40px; /* Adjust to not cover filename */
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
color: white;
display: flex;
@@ -43,13 +51,13 @@
border-radius: 0.25rem;
}
.tt-file-gallery-item:hover .tt-file-gallery-overlay {
.tt-file-gallery-item:hover .tt-file-gallery-thumbnail-wrapper .tt-file-gallery-overlay {
opacity: 1;
}
.tt-file-gallery-icon-container {
width: 100%;
height: 100px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
@@ -58,18 +66,40 @@
background-color: #f8f9fa;
text-decoration: none;
color: inherit;
transition: transform 0.2s;
}
.tt-file-gallery-item:hover .tt-file-gallery-icon-container {
transform: scale(1.05);
}
.tt-file-gallery-filename {
font-size: 0.8rem;
margin-top: 0.5rem;
width: 100%;
padding: 0 4px;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0 0.25rem;
line-height: 1.2;
}
.tt-file-gallery-filename > span {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
.tt-file-gallery-filename > i {
flex-shrink: 0;
}
/* --- Fullscreen Viewer Styles --- */
.tt-fullscreen-overlay {
@@ -0,0 +1,198 @@
/* --- tt-select.css --- */
.tt-select-modern {
position: relative;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.tt-select-modern .form-group {
margin-bottom: 0;
}
.tt-select-modern .tt-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
cursor: pointer;
text-align: left;
}
.tt-select-modern .tt-select-trigger.sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border-radius: 0.2rem;
}
.tt-select-modern .tt-select-trigger:hover {
border-color: #80bdff;
}
.tt-select-modern .tt-select-trigger.open {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.tt-select-modern .trigger-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.tt-select-modern .trigger-text.placeholder {
color: #6c757d;
}
.tt-select-modern .trigger-arrow {
margin-left: 8px;
transition: transform 0.2s ease;
}
.tt-select-modern .tt-select-trigger.open .trigger-arrow {
transform: rotate(180deg);
}
.tt-select-modern .tt-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
width: max-content;
z-index: 1050;
background-color: #fff;
border-radius: 0.3rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-10px);
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tt-select-modern .tt-select-dropdown.show {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.tt-select-modern .dropdown-header {
display: none; /* Hidden on desktop */
}
.tt-select-modern .search-input-wrapper {
padding: 0.5rem;
border-bottom: 1px solid #e9ecef;
}
.tt-select-modern .search-input {
width: 100%;
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 0.25rem;
}
.tt-select-modern .options-list {
list-style: none;
padding: 0.5rem 0;
margin: 0;
max-height: 250px;
overflow-y: auto;
}
.tt-select-modern .option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.15s ease;
user-select: none;
white-space: nowrap;
}
.tt-select-modern .option-label {
display: flex;
align-items: center;
}
.tt-select-modern .option-item input {
display: none;
}
.tt-select-modern .option-item:hover {
background-color: #f8f9fa;
}
.tt-select-modern .option-item.selected {
background-color: #e2f0ff;
font-weight: 600;
color: #0056b3;
}
.tt-select-modern .option-checkmark {
color: #0056b3;
}
.tt-select-modern .no-results {
padding: 0.5rem 1rem;
color: #6c757d;
text-align: center;
}
/* Mobile Compliance */
@media (max-width: 768px) {
.tt-select-modern .tt-select-trigger,
.tt-select-modern .option-item {
font-size: 1.1rem; /* Bigger font for readability */
}
.tt-select-modern .tt-select-dropdown.show {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
transform: none;
}
.tt-select-modern .dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background-color: #fff;
border-bottom: 1px solid #dee2e6;
font-size: 1.2rem;
font-weight: 600;
}
.tt-select-modern .close-btn {
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
border: none;
background: none;
}
.tt-select-modern .options-list {
max-height: none;
flex-grow: 1;
}
}
@@ -1,13 +1,17 @@
// tt-file-gallery.js
Vue.component('tt-file-gallery', {
props: {
files: { type: Array, default: () => [] }
files: { type: Array, default: () => [] },
editMode: { type: Boolean, default: false },
deleteMode: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
},
data() {
return {
fullscreenItem: null, // Holds the file being viewed
fullscreenItem: null,
currentImageIndex: 0,
// Zoom & Pan state
editingFile: null,
selectedFiles: [],
zoom: 1,
pan: { x: 0, y: 0 },
isPanning: false,
@@ -23,7 +27,6 @@ Vue.component('tt-file-gallery', {
return this.fullscreenItem && this.isImage(this.fullscreenItem);
},
imageTransformStyle() {
// Apply CSS transform for zoom and pan
const { x, y } = this.pan;
return {
transform: `translate(${x}px, ${y}px) scale(${this.zoom})`,
@@ -33,34 +36,33 @@ Vue.component('tt-file-gallery', {
},
fullscreenDownloadUrl() {
if (!this.fullscreenItem) return '#';
return `/File/download?id=${this.fullscreenItem.id}`;
return `/File/download?id=${this.fullscreenItem.fileId}`;
}
},
methods: {
// File type checks
isImage(file) {
return file.mimetype && file.mimetype.startsWith('image/');
},
isPdf(file) {
return file.mimetype === 'application/pdf';
},
// Get icon for non-image/pdf files
isImage: file => file.mimetype && file.mimetype.startsWith('image/'),
isPdf: file => file.mimetype === 'application/pdf',
getFileIcon(file) {
const extension = file.fileName?.split('.').pop().toLowerCase();
switch (extension) {
case 'doc':
case 'docx': return 'fas fa-file-word text-primary';
case 'xls':
case 'xlsx': return 'fas fa-file-excel text-success';
case 'zip':
case 'rar': return 'fas fa-file-archive text-warning';
case 'doc': case 'docx': return 'fas fa-file-word text-primary';
case 'xls': case 'xlsx': return 'fas fa-file-excel text-success';
case 'zip': case 'rar': return 'fas fa-file-archive text-warning';
default: return 'fas fa-file text-secondary';
}
},
// Viewer controls
toggleSelection(fileId) {
if (!this.selectable) return;
const index = this.selectedFiles.indexOf(fileId);
if (index > -1) {
this.selectedFiles.splice(index, 1);
} else {
this.selectedFiles.push(fileId);
}
this.$emit('selection-changed', this.selectedFiles);
},
openViewer(file) {
if(this.editingFile) return;
this.fullscreenItem = file;
if (this.isImage(file)) {
this.currentImageIndex = this.imageFiles.findIndex(img => img.id === file.id);
@@ -68,9 +70,7 @@ Vue.component('tt-file-gallery', {
this.resetZoomAndPan();
this.$nextTick(() => { this.$refs.viewer?.focus(); });
},
closeViewer() {
this.fullscreenItem = null;
},
closeViewer() { this.fullscreenItem = null; },
navigateImage(direction) {
const newIndex = this.currentImageIndex + direction;
if (newIndex >= 0 && newIndex < this.imageFiles.length) {
@@ -79,9 +79,8 @@ Vue.component('tt-file-gallery', {
this.resetZoomAndPan();
}
},
// Event handlers for keyboard and clicks
handleKeyDown(event) {
event.stopPropagation();
if (!this.fullscreenItem) return;
switch (event.key) {
case 'Escape': this.closeViewer(); break;
@@ -89,24 +88,16 @@ Vue.component('tt-file-gallery', {
case 'ArrowRight': this.isViewingImage && this.navigateImage(1); break;
}
},
// --- Zoom and Pan Methods ---
resetZoomAndPan() {
this.zoom = 1;
this.pan = { x: 0, y: 0 };
this.isPanning = false;
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.isPanning = false;
},
// Mouse Wheel Zoom
handleWheel(e) {
if (!this.isViewingImage) return;
e.preventDefault();
const scaleFactor = 0.2;
const newZoom = this.zoom - (e.deltaY > 0 ? scaleFactor : -scaleFactor);
this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom between 1x and 5x
this.zoom = Math.max(1, Math.min(newZoom, 5));
},
// Mouse Drag to Pan
onPanStart(e) {
if (this.zoom <= 1) return;
e.preventDefault();
@@ -119,79 +110,81 @@ Vue.component('tt-file-gallery', {
this.pan.x = e.clientX - this.panStart.x;
this.pan.y = e.clientY - this.panStart.y;
},
onPanEnd() {
this.isPanning = false;
},
onPanEnd() { this.isPanning = false; },
onTouchStart(e) { /* ... touch logic ... */ },
onTouchMove(e) { /* ... touch logic ... */ },
onTouchEnd(e) { /* ... touch logic ... */ },
// Touch Events for Mobile (Pinch-to-Zoom & Pan)
onTouchStart(e) {
if (this.zoom <= 1 && e.touches.length === 1) return;
e.preventDefault();
if (e.touches.length === 1) { // Pan
this.isPanning = true;
this.panStart.x = e.touches[0].clientX - this.pan.x;
this.panStart.y = e.touches[0].clientY - this.pan.y;
} else if (e.touches.length === 2) { // Zoom
this.lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
startEdit(file, event) {
event?.stopPropagation();
this.editingFile = { ...file }; // create a copy for editing
},
cancelEdit(event) {
console.log('Edit cancelled');
event?.stopPropagation();
this.editingFile = null;
},
saveEdit(event) {
event?.stopPropagation();
this.$emit('update-file', this.editingFile);
this.editingFile = null;
},
deleteFile(file, event) {
event?.stopPropagation();
if (confirm(`Sind Sie sicher, dass Sie die Datei "${file.fileName}" löschen möchten?`)) {
this.$emit('delete-file', file);
}
},
onTouchMove(e) {
if (!this.isPanning && e.touches.length !== 2) return;
e.preventDefault();
if (e.touches.length === 1 && this.isPanning) { // Pan
this.pan.x = e.touches[0].clientX - this.panStart.x;
this.pan.y = e.touches[0].clientY - this.panStart.y;
} else if (e.touches.length === 2) { // Zoom
const pinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const scaleFactor = 0.01;
const newZoom = this.zoom + (pinchDist - this.lastPinchDist) * scaleFactor;
this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom
this.lastPinchDist = pinchDist;
}
},
onTouchEnd(e) {
this.isPanning = false;
if (e.touches.length < 2) {
this.lastPinchDist = 0;
}
}
},
watch: {
fullscreenItem(newItem) {
// Prevent body scroll when viewer is open
document.body.style.overflow = newItem ? 'hidden' : '';
}
},
template: `
<div class="card">
<div class="card-header"><h5>Hochgeladene Dokumente</h5></div>
<div class="card-header"><i class="fas fa-images mr-2"></i><h5>Hochgeladene Dokumente</h5></div>
<div v-if="!files.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
<div v-else class="card-body">
<div class="tt-file-gallery-grid">
<div v-for="file in files" :key="file.id" class="tt-file-gallery-item" @click="openViewer(file)">
<template v-if="isImage(file)">
<img :src="'/File/show?id=' + file.id + '&size=small'" class="tt-file-gallery-thumbnail" :alt="file.fileName">
<div class="tt-file-gallery-overlay"><i class="fas fa-search-plus"></i></div>
</template>
<template v-else-if="isPdf(file)">
<div class="tt-file-gallery-icon-container"><i class="fas fa-file-pdf fa-3x text-danger"></i></div>
</template>
<template v-else>
<a :href="'/File/download?id=' + file.id" target="_blank" @click.stop class="tt-file-gallery-icon-container">
<i :class="getFileIcon(file)" class="fa-3x"></i>
</a>
</template>
<div class="tt-file-gallery-filename" :title="file.fileName">{{ file.fileName }}</div>
<div v-for="file in files" :key="file.id"
class="tt-file-gallery-item"
:class="[{ 'selected': selectable && selectedFiles.includes(file.id) }, file.class]"
@click="openViewer(file)">
<div v-if="selectable" class="selection-indicator" @click.stop="toggleSelection(file.id)">
<i :class="selectedFiles.includes(file.id) ? 'fas fa-check-circle text-primary' : 'far fa-circle text-muted'"></i>
</div>
<div class="tt-file-gallery-thumbnail-wrapper">
<template v-if="isImage(file)">
<img :src="'/File/show?id=' + file.fileId + '&size=small'" class="tt-file-gallery-thumbnail" :alt="file.fileName">
</template>
<template v-else-if="isPdf(file)">
<div class="tt-file-gallery-icon-container"><i class="fas fa-file-pdf fa-3x text-danger"></i></div>
</template>
<template v-else>
<a :href="'/File/download?id=' + file.fileId" target="_blank" @click.stop class="tt-file-gallery-icon-container">
<i :class="getFileIcon(file)" class="fa-3x"></i>
</a>
</template>
<div class="tt-file-gallery-overlay" @click.stop="openViewer(file)"><i class="fas fa-search-plus"></i></div>
</div>
<div class="tt-file-gallery-filename" :title="file.fileName">
<span>{{ file.fileName }}</span>
<i v-if="editMode && !editingFile" class="fas fa-edit text-primary ml-1 action-icon" @click="startEdit(file, $event)"></i>
<i v-if="deleteMode && !editingFile" class="fas fa-trash text-danger ml-1 action-icon" @click="deleteFile(file, $event)"></i>
</div>
</div>
</div>
</div>
<tt-modal v-if="editingFile" :show="true" title="Dokument bearbeiten" @close="cancelEdit" @submit="saveEdit" :delete="false">
<slot name="file-edit" :file="editingFile"></slot>
</tt-modal>
<div v-if="fullscreenItem" class="tt-fullscreen-overlay" @click.self="closeViewer" @keydown="handleKeyDown" tabindex="-1" ref="viewer">
<div class="tt-fullscreen-toolbar">
<a v-if="isViewingImage" :href="fullscreenDownloadUrl" download class="tt-fullscreen-btn" title="Download">
@@ -199,33 +192,19 @@ Vue.component('tt-file-gallery', {
</a>
<button class="tt-fullscreen-btn" @click="closeViewer" title="Close"><i class="fas fa-times"></i></button>
</div>
<div class="tt-fullscreen-content" @click.self="closeViewer">
<template v-if="isViewingImage">
<div class="tt-fullscreen-image-wrapper"
@wheel="handleWheel"
@mousedown="onPanStart"
@mousemove="onPanMove"
@mouseup="onPanEnd"
@mouseleave="onPanEnd"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd">
<img :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-image" :style="imageTransformStyle" @click.stop />
<div class="tt-fullscreen-image-wrapper" @wheel="handleWheel" @mousedown="onPanStart" @mousemove="onPanMove" @mouseup="onPanEnd" @mouseleave="onPanEnd">
<img :src="'/File/show?id=' + fullscreenItem.fileId" class="tt-fullscreen-image" :style="imageTransformStyle" @click.stop />
</div>
</template>
<template v-else-if="isPdf(fullscreenItem)">
<iframe :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-pdf" @click.stop></iframe>
<iframe :src="'/File/show?id=' + fullscreenItem.fileId" class="tt-fullscreen-pdf" @click.stop></iframe>
</template>
</div>
<template v-if="isViewingImage">
<button class="tt-fullscreen-nav-btn left" @click.stop="navigateImage(-1)" v-if="currentImageIndex > 0">
<i class="fas fa-chevron-left"></i>
</button>
<button class="tt-fullscreen-nav-btn right" @click.stop="navigateImage(1)" v-if="currentImageIndex < imageFiles.length - 1">
<i class="fas fa-chevron-right"></i>
</button>
<button class="tt-fullscreen-nav-btn left" @click.stop="navigateImage(-1)" v-if="currentImageIndex > 0"><i class="fas fa-chevron-left"></i></button>
<button class="tt-fullscreen-nav-btn right" @click.stop="navigateImage(1)" v-if="currentImageIndex < imageFiles.length - 1"><i class="fas fa-chevron-right"></i></button>
</template>
</div>
</div>
@@ -12,7 +12,6 @@ Vue.component('tt-modal', {
if (!newVal) {
this.$emit('close')
}
// if show now is true then focus the first input element
if (newVal) {
this.$nextTick(() => {
const input = this.$refs.modal.querySelector('input')
+105 -157
View File
@@ -1,50 +1,42 @@
Vue.component('tt-select', {
props: {
options: { type: Array, required: true },
label: { type: String, default: null },
required: { type: Boolean, default: false },
value: { type: [String, Number, Array], default: null },
disabled: { type: Boolean, default: false },
suffix: { type: String, default: null },
sm: { type: Boolean, default: false },
row: { type: Boolean, default: false },
multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: true }
options: {type: Array, required: true},
label: {type: String, default: null},
required: {type: Boolean, default: false},
value: {type: [String, Number, Array], default: null},
disabled: {type: Boolean, default: false},
suffix: {type: String, default: null},
sm: {type: Boolean, default: false},
row: {type: Boolean, default: false},
multiple: {type: Boolean, default: false},
searchable: {type: Boolean, default: true}
},
data() {
return {
open: false,
searchQuery: '',
// internal model: array if multiple, else primitive
selected: this.multiple
? (Array.isArray(this.value) ? [...this.value] : [])
: this.value
selected: this.multiple ? (Array.isArray(this.value) ? [...this.value] : []) : this.value
};
},
watch: {
// keep internal model in sync with external v-model
value(newVal) {
this.selected = this.multiple
? (Array.isArray(newVal) ? [...newVal] : [])
: newVal;
this.selected = this.multiple ? (Array.isArray(newVal) ? [...newVal] : []) : newVal;
},
// Enhancement: Handle focus and scroll on open
open(isOpen) {
if (isOpen) {
this.$nextTick(() => {
if (this.$refs.dropdownMenu) {
// 2. Scroll to the top when opened
this.$refs.dropdownMenu.scrollTop = 0;
}
if (this.searchable && this.$refs.searchInput) {
// 1. Focus the input when opened
this.$refs.searchInput.focus();
}
});
if (!isOpen) {
this.searchQuery = ''; // Reset search on close
return;
}
this.$nextTick(() => {
if (this.searchable && this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
if (this.$refs.optionsList) {
this.$refs.optionsList.scrollTop = 0;
}
});
}
},
@@ -57,55 +49,42 @@ Vue.component('tt-select', {
},
computed: {
// normalize all options to { value, text, disabled }
normalizedOptions() {
return this.options.map(opt => {
if (opt !== null && typeof opt === 'object') {
return {
value: opt.value,
text: opt.text,
disabled: !!opt.disabled
};
}
return {
value: opt,
text: String(opt),
disabled: false
};
});
},
// filter by searchQuery
filteredOptions() {
if (!this.searchable || !this.searchQuery) {
return this.normalizedOptions;
}
const q = this.searchQuery.toLowerCase();
return this.normalizedOptions.filter(o =>
o.text.toLowerCase().includes(q)
return this.options.map(opt =>
(opt !== null && typeof opt === 'object')
? {value: opt.value, text: opt.text, disabled: !!opt.disabled}
: {value: opt, text: String(opt), disabled: false}
);
},
// what to show on the button
filteredOptions() {
if (!this.searchable || !this.searchQuery) return this.normalizedOptions;
const q = this.searchQuery.toLowerCase();
return this.normalizedOptions.filter(o => o.text.toLowerCase().includes(q));
},
displayText() {
if (this.multiple) {
const arr = Array.isArray(this.selected) ? this.selected : [];
const count = arr.length;
if (count === 0) return '';
if (count === 1) {
const o = this.normalizedOptions.find(x => x.value === arr[0]);
return o
? o.text + (this.suffix ? ' ' + this.suffix : '')
: '';
}
// more than one selected
return `${count} ausgewählt`;
const addSuffix = text => text ? `${text}${this.suffix ? ` ${this.suffix}` : ''}` : '';
if (!this.multiple) {
const opt = this.normalizedOptions.find(o => o.value === this.selected);
return opt ? addSuffix(opt.text) : 'Auswählen...';
}
// single select
const sel = this.normalizedOptions.find(o => o.value === this.selected);
return sel
? sel.text + (this.suffix ? ' ' + this.suffix : '')
: '';
const count = Array.isArray(this.selected) ? this.selected.length : 0;
if (count === 0) return 'Auswählen...';
if (count === 1) {
const opt = this.normalizedOptions.find(o => o.value === this.selected[0]);
return opt ? addSuffix(opt.text) : '';
}
return `${count} ausgewählt`;
},
hasSelection() {
if (this.multiple) {
return Array.isArray(this.selected) && this.selected.length > 0;
}
return this.selected !== null && this.selected !== undefined && this.selected !== '';
}
},
@@ -116,7 +95,7 @@ Vue.component('tt-select', {
},
onClickOutside(e) {
if (!this.$el.contains(e.target)) {
if (this.$el && !this.$el.contains(e.target)) {
this.open = false;
}
},
@@ -129,111 +108,80 @@ Vue.component('tt-select', {
selectOption(opt) {
if (opt.disabled) return;
if (this.multiple) {
const arr = Array.isArray(this.selected) ? [...this.selected] : [];
const idx = arr.indexOf(opt.value);
if (idx > -1) arr.splice(idx, 1);
else arr.push(opt.value);
this.selected = arr;
this.$emit('input', arr);
if (this.multiple) {
const newSelection = Array.isArray(this.selected) ? [...this.selected] : [];
const index = newSelection.indexOf(opt.value);
if (index > -1) {
newSelection.splice(index, 1);
} else {
newSelection.push(opt.value);
}
this.selected = newSelection;
} else {
this.selected = opt.value;
this.$emit('input', opt.value);
this.open = false;
this.open = false; // Close on selection for single mode
}
this.$emit('input', this.selected);
},
// Enhancement: Handle Enter key press on search input
handleEnter() {
// 4. If search is active and only one result is left, select it
if (this.searchQuery && this.filteredOptions.length === 1) {
this.selectOption(this.filteredOptions[0]);
// Ensure dropdown closes for both single and multi-select mode
this.open = false;
if (!this.multiple) this.open = false;
}
}
},
template: `
<div class="form-group" :class="{ row: row }">
<label v-if="label"
:for="label"
:class="{
'col-form-label': row,
'col-sm-4': row,
'col-form-label-sm': sm && row
}">
<div class="form-group" :class="[{ 'row': row }, 'tt-select-modern']">
<label v-if="label" :for="'tt-select-' + _uid"
:class="['col-form-label', { 'col-sm-4': row, 'col-form-label-sm': sm && row }]">
{{ label }}
</label>
<div :class="row ? 'col-sm-8 p-0' : ''">
<div class="dropdown" :class="{ show: open }">
<button type="button"
class="btn btn-outline-secondary form-control"
:class="{ 'form-control-sm': sm, 'btn-sm': sm }"
<div :class="{ 'col-sm-8 p-0': row }">
<div class="tt-select-container">
<button type="button" class="tt-select-trigger"
:id="'tt-select-' + _uid"
:class="{ 'sm': sm, 'open': open }"
:disabled="disabled"
@click.stop="toggleDropdown"
style="display:flex; align-items:center;">
<span style="
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
text-align: left;
">
{{ displayText || 'Auswählen...' }}
</span>
<i class="fas fa-chevron-down ml-2"></i>
@click.stop="toggleDropdown">
<span class="trigger-text" :class="{'placeholder': !hasSelection}">{{ displayText }}</span>
<i class="fas fa-chevron-down trigger-arrow"></i>
</button>
<div class="dropdown-menu p-2"
ref="dropdownMenu"
:class="{ show: open }"
style="
min-width: 100%;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
">
<input v-if="searchable"
ref="searchInput"
type="text"
class="form-control mb-2"
v-model="searchQuery"
:disabled="disabled"
placeholder="Suchen..."
@keydown.enter.prevent="handleEnter">
<div v-if="!filteredOptions.length && searchQuery" class="dropdown-item disabled text-muted">
Keine Ergebnisse gefunden.
<div class="tt-select-dropdown" :class="{ show: open }" @click.stop>
<div class="dropdown-header">
<span>{{ label || 'Auswählen' }}</span>
<button type="button" class="close-btn" @click="toggleDropdown">&times;</button>
</div>
<div v-for="opt in filteredOptions"
:key="opt.value"
class="dropdown-item px-2 py-1"
:class="{ disabled: opt.disabled }"
@click.stop>
<label class="d-flex align-items-center m-0 w-100" style="cursor: pointer;">
<input v-if="multiple"
type="checkbox"
class="form-check-input mr-2"
:checked="isSelected(opt)"
:disabled="opt.disabled"
@change.prevent="selectOption(opt)">
<input v-else
type="radio"
class="form-check-input mr-2"
:name="label"
:value="opt.value"
:checked="isSelected(opt)"
:disabled="opt.disabled"
@change.prevent="selectOption(opt)">
<span>
{{ opt.text }}<span v-if="suffix"> {{ suffix }}</span>
</span>
</label>
<div v-if="searchable" class="search-input-wrapper">
<input ref="searchInput" type="text" class="search-input"
v-model="searchQuery" :disabled="disabled" placeholder="Suchen..."
@keydown.enter.prevent="handleEnter">
</div>
<ul class="options-list" ref="optionsList">
<li v-if="!filteredOptions.length" class="no-results">
Keine Ergebnisse gefunden.
</li>
<li v-for="opt in filteredOptions" :key="opt.value"
class="option-item"
:class="{ 'selected': isSelected(opt), 'disabled': opt.disabled }"
@click="selectOption(opt)">
<div class="option-label">
<input :type="multiple ? 'checkbox' : 'radio'"
:name="'option-' + _uid"
:value="opt.value"
:checked="isSelected(opt)"
:disabled="opt.disabled">
<span>{{ opt.text }}<span v-if="suffix"> {{ suffix }}</span></span>
</div>
<i v-if="isSelected(opt)" class="fas fa-check option-checkmark"></i>
</li>
</ul>
</div>
</div>
</div>
@@ -187,6 +187,7 @@ Vue.component('tt-table-crud', {
tableConfig() {
return {
key: this.crudConfig.key,
customRowClass: this.crudConfig.customRowClass,
tableHeader: this.crudConfig.tableHeader,
headers: this.crudConfig.columns.filter(column => column.table !== false).map(column => {
return {text: column.text, key: column.key, ...column.table, filterOptions: column?.table?.filterOptions ?? column?.modal?.items ?? [], priority: column.priority}