248 lines
7.6 KiB
Vue
248 lines
7.6 KiB
Vue
<template>
|
|
<div
|
|
class="dashboard-widget"
|
|
:class="{ 'is-dragging': isDragging }"
|
|
:data-widget-id="widgetId"
|
|
>
|
|
<header class="dashboard-widget__titlebar">
|
|
<span class="dashboard-widget__drag-handle" title="Verschieben" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd">⋮⋮</span>
|
|
<span class="dashboard-widget__title">{{ title }}</span>
|
|
<slot name="title-actions"></slot>
|
|
</header>
|
|
<div class="dashboard-widget__frame">
|
|
<div v-if="loading" class="dashboard-widget__state">Laden…</div>
|
|
<div v-else-if="error" class="dashboard-widget__state dashboard-widget__error">{{ error }}</div>
|
|
<div v-else class="dashboard-widget__body">
|
|
<component :is="widgetComponent" :data="data" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { mapState } from 'vuex';
|
|
import apiClient from '@/utils/axios.js';
|
|
import FalukantWidget from './widgets/FalukantWidget.vue';
|
|
import NewsWidget from './widgets/NewsWidget.vue';
|
|
import ListWidget from './widgets/ListWidget.vue';
|
|
import BirthdayWidget from './widgets/BirthdayWidget.vue';
|
|
import UpcomingEventsWidget from './widgets/UpcomingEventsWidget.vue';
|
|
import MiniCalendarWidget from './widgets/MiniCalendarWidget.vue';
|
|
|
|
function getWidgetComponent(endpoint) {
|
|
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
|
|
const ep = endpoint.toLowerCase();
|
|
if (ep.includes('falukant')) return FalukantWidget;
|
|
if (ep.includes('news')) return NewsWidget;
|
|
if (ep.includes('calendar/widget/birthdays')) return BirthdayWidget;
|
|
if (ep.includes('calendar/widget/upcoming')) return UpcomingEventsWidget;
|
|
if (ep.includes('calendar/widget/mini')) return MiniCalendarWidget;
|
|
return ListWidget;
|
|
}
|
|
|
|
export default {
|
|
name: 'DashboardWidget',
|
|
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget },
|
|
props: {
|
|
widgetId: { type: String, required: true },
|
|
title: { type: String, required: true },
|
|
endpoint: { type: String, required: true },
|
|
pauseFetch: { type: Boolean, default: false },
|
|
requestCounter: { type: Number, default: undefined }
|
|
},
|
|
emits: ['drag-start', 'drag-end'],
|
|
data() {
|
|
return {
|
|
data: null,
|
|
loading: false,
|
|
error: null,
|
|
isDragging: false,
|
|
_daemonMessageHandler: null
|
|
};
|
|
},
|
|
computed: {
|
|
...mapState(['socket', 'daemonSocket']),
|
|
isFalukantWidget() {
|
|
return this.endpoint && String(this.endpoint).includes('falukant');
|
|
},
|
|
widgetComponent() {
|
|
return getWidgetComponent(this.endpoint);
|
|
}
|
|
},
|
|
watch: {
|
|
endpoint: { handler: 'fetchData', immediate: false },
|
|
requestCounter: { handler: 'fetchData', immediate: false },
|
|
pauseFetch: { handler(now) { if (!now) this.fetchData(); } },
|
|
socket(newVal, oldVal) {
|
|
if (!this.isFalukantWidget) return;
|
|
if (oldVal) this.teardownSocketListeners();
|
|
if (newVal) this.setupSocketListeners();
|
|
},
|
|
daemonSocket(newVal, oldVal) {
|
|
if (!this.isFalukantWidget) return;
|
|
if (oldVal) this.teardownSocketListeners();
|
|
if (newVal) this.setupSocketListeners();
|
|
}
|
|
},
|
|
mounted() {
|
|
if (!this.pauseFetch && this.endpoint) this.fetchData();
|
|
if (this.isFalukantWidget) this.setupSocketListeners();
|
|
},
|
|
beforeUnmount() {
|
|
if (this.isFalukantWidget) this.teardownSocketListeners();
|
|
},
|
|
methods: {
|
|
setupSocketListeners() {
|
|
this.teardownSocketListeners();
|
|
const daemonEvents = ['falukantUpdateStatus', 'stock_change', 'familychanged'];
|
|
if (this.daemonSocket) {
|
|
this._daemonMessageHandler = (event) => {
|
|
if (event.data === 'ping') return;
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (daemonEvents.includes(data.event)) this.fetchData();
|
|
} catch (_) {}
|
|
};
|
|
this.daemonSocket.addEventListener('message', this._daemonMessageHandler);
|
|
}
|
|
if (this.socket) {
|
|
this.socket.on('falukantUpdateStatus', () => this.fetchData());
|
|
this.socket.on('falukantBranchUpdate', () => this.fetchData());
|
|
}
|
|
},
|
|
teardownSocketListeners() {
|
|
if (this.daemonSocket && this._daemonMessageHandler) {
|
|
this.daemonSocket.removeEventListener('message', this._daemonMessageHandler);
|
|
this._daemonMessageHandler = null;
|
|
}
|
|
if (this.socket) {
|
|
this.socket.off('falukantUpdateStatus');
|
|
this.socket.off('falukantBranchUpdate');
|
|
}
|
|
},
|
|
async fetchData() {
|
|
if (!this.endpoint || this.pauseFetch) return;
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
let path = this.endpoint.startsWith('/') ? this.endpoint : `/${this.endpoint}`;
|
|
path = path.startsWith('/api') ? path : `/api${path}`;
|
|
if (this.requestCounter !== undefined && this.requestCounter !== null) {
|
|
const sep = path.includes('?') ? '&' : '?';
|
|
path = `${path}${sep}counter=${Number(this.requestCounter)}`;
|
|
}
|
|
const res = await apiClient.get(path);
|
|
this.data = res.data;
|
|
} catch (e) {
|
|
this.error = e.response?.data?.error || e.message || 'Fehler beim Laden';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
onDragStart(e) {
|
|
this.isDragging = true;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', this.widgetId);
|
|
e.dataTransfer.setData('application/x-widget-id', this.widgetId);
|
|
const dragGhost = this.$el.cloneNode(true);
|
|
dragGhost.style.position = 'absolute';
|
|
dragGhost.style.top = '-9999px';
|
|
dragGhost.style.width = `${this.$el.offsetWidth}px`;
|
|
dragGhost.style.opacity = '0.8';
|
|
dragGhost.style.transform = 'rotate(-2deg)';
|
|
dragGhost.style.pointerEvents = 'none';
|
|
document.body.appendChild(dragGhost);
|
|
const rect = this.$el.getBoundingClientRect();
|
|
const offsetX = e.clientX - rect.left;
|
|
const offsetY = e.clientY - rect.top;
|
|
e.dataTransfer.setDragImage(dragGhost, offsetX, offsetY);
|
|
setTimeout(() => {
|
|
if (dragGhost.parentNode) dragGhost.parentNode.removeChild(dragGhost);
|
|
}, 0);
|
|
this.$emit('drag-start', { widgetId: this.widgetId, event: e });
|
|
},
|
|
onDragEnd() {
|
|
this.isDragging = false;
|
|
this.$emit('drag-end');
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dashboard-widget {
|
|
min-height: 0;
|
|
max-height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--dashboard-widget-bg, #fff);
|
|
border: 1px solid var(--dashboard-widget-border, #dee2e6);
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
overflow: hidden;
|
|
transition: opacity 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.dashboard-widget.is-dragging {
|
|
opacity: 0.4;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
|
cursor: grabbing;
|
|
border: 2px dashed #0d6efd;
|
|
}
|
|
|
|
.dashboard-widget__titlebar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
background: var(--color-primary-orange-light, #fff8e1);
|
|
border-bottom: 1px solid var(--color-text-secondary, #dee2e6);
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.dashboard-widget__drag-handle {
|
|
cursor: grab;
|
|
color: #868e96;
|
|
user-select: none;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.dashboard-widget__drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.dashboard-widget__title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.dashboard-widget__frame {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 12px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.dashboard-widget__state {
|
|
color: #666;
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.dashboard-widget__error {
|
|
color: #c92a2a;
|
|
}
|
|
|
|
.dashboard-widget__body {
|
|
font-size: 0.9rem;
|
|
color: #333;
|
|
}
|
|
</style>
|