new version of rmlworkorder

This commit is contained in:
Luca Haid
2025-07-23 20:44:25 +02:00
parent f7218ab144
commit 8621cba7ff
18 changed files with 1379 additions and 726 deletions
@@ -0,0 +1,181 @@
.tt-file-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
}
.tt-file-gallery-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
cursor: pointer;
}
.tt-file-gallery-thumbnail {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 0.25rem;
border: 1px solid #dee2e6;
transition: transform 0.2s;
}
.tt-file-gallery-item:hover .tt-file-gallery-thumbnail {
transform: scale(1.05);
}
.tt-file-gallery-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 40px; /* Adjust to not cover filename */
background: rgba(0, 0, 0, 0.4);
color: white;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
border-radius: 0.25rem;
}
.tt-file-gallery-item:hover .tt-file-gallery-overlay {
opacity: 1;
}
.tt-file-gallery-icon-container {
width: 100%;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
background-color: #f8f9fa;
text-decoration: none;
color: inherit;
}
.tt-file-gallery-filename {
font-size: 0.8rem;
margin-top: 0.5rem;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
/* --- Fullscreen Viewer Styles --- */
.tt-fullscreen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
outline: none;
}
.tt-fullscreen-toolbar {
position: absolute;
top: 0;
right: 0;
padding: 15px;
display: flex;
gap: 15px;
z-index: 10001;
}
.tt-fullscreen-btn {
background: rgba(0, 0, 0, 0.3);
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
transition: background-color 0.2s;
}
.tt-fullscreen-btn:hover {
background: rgba(0, 0, 0, 0.6);
}
.tt-fullscreen-content {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.tt-fullscreen-image-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden; /* Important for panning */
}
.tt-fullscreen-image {
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
will-change: transform; /* Performance hint for browser */
}
.tt-fullscreen-pdf {
width: calc(100vw - 40px);
height: calc(100vh - 40px);
max-width: 1600px; /* Optional: max width for very large screens */
border: none;
background-color: white;
}
.tt-fullscreen-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.3);
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.2s;
}
.tt-fullscreen-nav-btn:hover {
background: rgba(0, 0, 0, 0.6);
}
.tt-fullscreen-nav-btn.left {
left: 15px;
}
.tt-fullscreen-nav-btn.right {
right: 15px;
}
@@ -0,0 +1,233 @@
Vue.component('tt-file-gallery', {
props: {
files: { type: Array, default: () => [] }
},
data() {
return {
fullscreenItem: null, // Holds the file being viewed
currentImageIndex: 0,
// Zoom & Pan state
zoom: 1,
pan: { x: 0, y: 0 },
isPanning: false,
panStart: { x: 0, y: 0 },
lastPinchDist: 0,
}
},
computed: {
imageFiles() {
return this.files.filter(this.isImage);
},
isViewingImage() {
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})`,
cursor: this.isPanning ? 'grabbing' : 'grab',
transition: this.isPanning ? 'none' : 'transform 0.2s',
};
},
fullscreenDownloadUrl() {
if (!this.fullscreenItem) return '#';
return `/File/download?id=${this.fullscreenItem.id}`;
}
},
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
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';
default: return 'fas fa-file text-secondary';
}
},
// Viewer controls
openViewer(file) {
this.fullscreenItem = file;
if (this.isImage(file)) {
this.currentImageIndex = this.imageFiles.findIndex(img => img.id === file.id);
}
this.resetZoomAndPan();
this.$nextTick(() => { this.$refs.viewer?.focus(); });
},
closeViewer() {
this.fullscreenItem = null;
},
navigateImage(direction) {
const newIndex = this.currentImageIndex + direction;
if (newIndex >= 0 && newIndex < this.imageFiles.length) {
this.currentImageIndex = newIndex;
this.fullscreenItem = this.imageFiles[newIndex];
this.resetZoomAndPan();
}
},
// Event handlers for keyboard and clicks
handleKeyDown(event) {
if (!this.fullscreenItem) return;
switch (event.key) {
case 'Escape': this.closeViewer(); break;
case 'ArrowLeft': this.isViewingImage && this.navigateImage(-1); break;
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;
},
// 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
},
// Mouse Drag to Pan
onPanStart(e) {
if (this.zoom <= 1) return;
e.preventDefault();
this.isPanning = true;
this.panStart.x = e.clientX - this.pan.x;
this.panStart.y = e.clientY - this.pan.y;
},
onPanMove(e) {
if (!this.isPanning) return;
this.pan.x = e.clientX - this.panStart.x;
this.pan.y = e.clientY - this.panStart.y;
},
onPanEnd() {
this.isPanning = false;
},
// 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
);
}
},
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 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>
</div>
</div>
<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">
<i class="fas fa-download"></i>
</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>
</template>
<template v-else-if="isPdf(fullscreenItem)">
<iframe :src="'/File/show?id=' + fullscreenItem.id" 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>
</template>
</div>
</div>
`
});
@@ -181,7 +181,7 @@ Vue.component('tt-table-crud', {
key: this.crudConfig.key,
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?.modal?.items, priority: column.priority}
return {text: column.text, key: column.key, ...column.table, filterOptions: column?.table?.filterOptions ?? column?.modal?.items ?? [], priority: column.priority}
})
}
}, modalConfig() {
+6 -6
View File
@@ -201,9 +201,9 @@ Vue.component('tt-table', {
.isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key])
.format('DD.MM.YYYY HH:mm')) : ''
}}</span>
<i v-else-if="column.filter === 'iconSelect'"
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
<span v-else-if="column.filter === 'autocomplete'">
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}
@@ -236,9 +236,9 @@ Vue.component('tt-table', {
}}</span>
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }}</span>
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
<i v-else-if="column.filter === 'iconSelect'"
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
<span v-else-if="column.filter === 'autocomplete'">
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}</span>