Add dashboard functionality: Integrate dashboardRouter and UserDashboard model, enabling user-specific dashboard configurations. Update LoggedInView to support dynamic widget management, including adding, removing, and saving widget configurations, enhancing user experience and interactivity.

This commit is contained in:
Torsten Schulz (local)
2026-01-29 16:52:54 +01:00
parent 9519846489
commit 8d2db95540
9 changed files with 675 additions and 38 deletions

View File

@@ -0,0 +1,218 @@
<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">
<slot :data="data">
<template v-if="dataList.length">
<ul class="dashboard-widget__list">
<li v-for="(item, i) in dataList" :key="i" class="dashboard-widget__list-item">
<span v-if="item.datum" class="dashboard-widget__date">{{ formatDatum(item.datum) }}</span>
<span v-if="item.titel" class="dashboard-widget__title-text">{{ item.titel }}</span>
<span v-else-if="item.label" class="dashboard-widget__title-text">{{ item.label }}</span>
<p v-if="item.beschreibung" class="dashboard-widget__desc">{{ item.beschreibung }}</p>
</li>
</ul>
</template>
<template v-else>{{ defaultContent }}</template>
</slot>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: 'DashboardWidget',
props: {
widgetId: { type: String, required: true },
title: { type: String, required: true },
endpoint: { type: String, required: true },
/** Wenn true, wird nicht automatisch geladen (z. B. bei Bearbeitung). */
pauseFetch: { type: Boolean, default: false }
},
emits: ['drag-start', 'drag-end'],
data() {
return {
data: null,
loading: false,
error: null,
isDragging: false
};
},
computed: {
dataList() {
if (!Array.isArray(this.data) || this.data.length === 0) return [];
const first = this.data[0];
if (first !== null && typeof first === 'object') return this.data;
return [];
},
defaultContent() {
if (this.data == null) return '';
if (Array.isArray(this.data)) {
return this.data.length === 0 ? 'Keine Einträge' : `(${this.data.length} Einträge)`;
}
if (typeof this.data === 'object') {
const keys = Object.keys(this.data);
return keys.length === 0 ? '—' : `(${keys.length} Felder)`;
}
return String(this.data);
}
},
watch: {
endpoint: { handler: 'fetchData', immediate: false },
pauseFetch: { handler(now) { if (!now) this.fetchData(); } }
},
mounted() {
if (!this.pauseFetch && this.endpoint) this.fetchData();
},
methods: {
async fetchData() {
if (!this.endpoint || this.pauseFetch) return;
this.loading = true;
this.error = null;
try {
const path = this.endpoint.startsWith('/') ? this.endpoint : `/${this.endpoint}`;
const url = path.startsWith('/api') ? path : `/api${path}`;
const res = await apiClient.get(url);
this.data = res.data;
} catch (e) {
this.error = e.response?.data?.message || 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);
this.$emit('drag-start', { widgetId: this.widgetId, event: e });
},
onDragEnd() {
this.isDragging = false;
this.$emit('drag-end');
},
formatDatum(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
}
}
};
</script>
<style scoped>
.dashboard-widget {
min-height: 200px;
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;
}
.dashboard-widget.is-dragging {
opacity: 0.7;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.dashboard-widget__titlebar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--dashboard-widget-title-bg, #f1f3f5);
border-bottom: 1px solid var(--dashboard-widget-border, #dee2e6);
font-weight: 600;
font-size: 0.95rem;
color: var(--dashboard-widget-title-color, #333);
}
.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;
}
.dashboard-widget__frame {
flex: 1;
min-height: 160px;
padding: 12px;
overflow: 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;
}
.dashboard-widget__list {
list-style: none;
margin: 0;
padding: 0;
}
.dashboard-widget__list-item {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.dashboard-widget__list-item:last-child {
border-bottom: none;
}
.dashboard-widget__date {
display: block;
font-size: 0.8rem;
color: #666;
margin-bottom: 2px;
}
.dashboard-widget__title-text {
font-weight: 600;
color: #333;
}
.dashboard-widget__desc {
margin: 4px 0 0 0;
font-size: 0.85rem;
color: #555;
line-height: 1.4;
}
</style>

View File

@@ -1,67 +1,368 @@
<template>
<div class="home-logged-in">
<h1>Willkommen zurück!</h1>
<p>Schön, dass du wieder da bist.</p>
<TermineWidget />
<div class="actions">
<button @click="handleLogout" class="logout-btn">Logout</button>
</div>
<div class="home-logged-in">
<header class="dashboard-header">
<h1>Willkommen zurück!</h1>
<p class="dashboard-subtitle">Schön, dass du wieder da bist.</p>
<div class="dashboard-toolbar">
<button
v-if="!editMode"
type="button"
class="btn-edit"
@click="editMode = true"
>
Dashboard bearbeiten
</button>
<template v-else>
<button type="button" class="btn-add" @click="addWidget">
+ Widget hinzufügen
</button>
<button type="button" class="btn-done" @click="doneEditing">
Fertig
</button>
</template>
<button type="button" class="logout-btn" @click="handleLogout">
Logout
</button>
</div>
</header>
<div
v-if="loadError"
class="dashboard-message dashboard-error"
>
{{ loadError }}
</div>
<div
v-else
class="dashboard-grid"
@dragover.prevent="onGridDragover"
@drop="onGridDrop"
>
<template v-for="(w, index) in widgets" :key="w.id">
<div
class="dashboard-grid-cell"
:class="{ 'drop-target': dragOverIndex === index && draggedIndex !== index }"
@dragover.prevent="() => setDropTarget(index)"
@dragleave="clearDropTarget"
@drop="onDrop(index)"
>
<DashboardWidget
v-if="!editMode"
:widget-id="w.id"
:title="w.title"
:endpoint="w.endpoint"
@drag-start="() => (draggedIndex = index)"
@drag-end="() => (draggedIndex = null)"
/>
<div v-else class="dashboard-widget-edit">
<div class="widget-edit-fields">
<input
v-model="w.title"
type="text"
placeholder="Titel"
class="widget-edit-input"
/>
<input
v-model="w.endpoint"
type="text"
placeholder="Endpoint (z. B. /api/termine)"
class="widget-edit-input widget-edit-endpoint"
/>
</div>
<button
type="button"
class="btn-remove"
title="Widget entfernen"
@click="removeWidget(index)"
>
Entfernen
</button>
</div>
</div>
</template>
</div>
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
<p>Noch keine Widgets. Klicke auf „Dashboard bearbeiten“ und dann „+ Widget hinzufügen“.</p>
</div>
<div class="actions">
<!-- Platz für weitere Aktionen -->
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import TermineWidget from '@/components/TermineWidget.vue';
import apiClient from '@/utils/axios.js';
import DashboardWidget from '@/components/DashboardWidget.vue';
function generateId() {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `w-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export default {
name: 'HomeLoggedInView',
components: {
TermineWidget
name: 'HomeLoggedInView',
components: { DashboardWidget },
data() {
return {
widgets: [],
loading: true,
loadError: null,
editMode: false,
draggedIndex: null,
dragOverIndex: null
};
},
mounted() {
this.loadConfig();
},
methods: {
...mapActions(['logout']),
handleLogout() {
this.logout();
},
methods: {
...mapActions(['logout']),
handleLogout() {
this.logout();
async loadConfig() {
this.loading = true;
this.loadError = null;
try {
const { data } = await apiClient.get('/api/dashboard/config');
let list = Array.isArray(data?.widgets) ? [...data.widgets] : [];
if (list.length === 0) {
list = [{ id: generateId(), title: 'Termine', endpoint: '/api/termine' }];
this.widgets = list;
await this.saveConfig();
} else {
this.widgets = list;
}
} catch (e) {
this.loadError = e.response?.data?.error || e.message || 'Dashboard konnte nicht geladen werden.';
} finally {
this.loading = false;
}
},
async saveConfig() {
try {
await apiClient.put('/api/dashboard/config', { widgets: this.widgets });
} catch (e) {
console.error('Dashboard speichern fehlgeschlagen:', e);
}
},
addWidget() {
this.widgets.push({
id: generateId(),
title: 'Neues Widget',
endpoint: '/api/termine'
});
this.saveConfig();
},
removeWidget(index) {
this.widgets.splice(index, 1);
this.saveConfig();
},
doneEditing() {
this.editMode = false;
this.saveConfig();
},
setDropTarget(index) {
this.dragOverIndex = index;
},
clearDropTarget() {
this.dragOverIndex = null;
},
onGridDragover() {
this.dragOverIndex = this.widgets.length;
},
onGridDrop() {
if (this.draggedIndex == null) return;
const from = this.draggedIndex;
const to = this.widgets.length;
if (from === to || from === to - 1) {
this.draggedIndex = null;
this.dragOverIndex = null;
return;
}
const item = this.widgets.splice(from, 1)[0];
this.widgets.splice(to, 0, item);
this.draggedIndex = null;
this.dragOverIndex = null;
this.saveConfig();
},
onDrop(toIndex) {
if (this.draggedIndex == null) return;
const from = this.draggedIndex;
const to = toIndex;
if (from === to) {
this.draggedIndex = null;
this.dragOverIndex = null;
return;
}
const item = this.widgets.splice(from, 1)[0];
this.widgets.splice(to, 0, item);
this.draggedIndex = null;
this.dragOverIndex = null;
this.saveConfig();
}
}
};
</script>
<style scoped>
.home-logged-in {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.home-logged-in h1 {
color: #333;
margin-bottom: 10px;
.dashboard-header {
margin-bottom: 24px;
}
.home-logged-in p {
color: #666;
margin-bottom: 20px;
.dashboard-header h1 {
color: #333;
margin: 0 0 4px 0;
}
.actions {
margin-top: 30px;
text-align: center;
.dashboard-subtitle {
color: #666;
margin: 0 0 16px 0;
}
.dashboard-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.btn-edit,
.btn-add,
.btn-done {
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #ced4da;
background: #fff;
color: #495057;
cursor: pointer;
font-size: 0.9rem;
}
.btn-edit:hover,
.btn-add:hover,
.btn-done:hover {
background: #f1f3f5;
}
.btn-add {
border-color: #0d6efd;
color: #0d6efd;
}
.btn-done {
border-color: #198754;
color: #198754;
}
.logout-btn {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-left: auto;
background: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.logout-btn:hover {
background: #c82333;
background: #c82333;
}
</style>
.dashboard-message {
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.dashboard-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.dashboard-grid-cell {
min-height: 200px;
}
.dashboard-grid-cell.drop-target {
outline: 2px dashed #0d6efd;
outline-offset: 4px;
border-radius: 8px;
}
.dashboard-widget-edit {
min-height: 200px;
padding: 12px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.widget-edit-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.widget-edit-input {
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
}
.widget-edit-endpoint {
font-family: monospace;
font-size: 0.85rem;
}
.btn-remove {
align-self: flex-start;
padding: 6px 12px;
border: 1px solid #dc3545;
background: #fff;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-remove:hover {
background: #dc3545;
color: #fff;
}
.dashboard-empty {
padding: 32px;
text-align: center;
color: #666;
background: #f8f9fa;
border-radius: 8px;
border: 1px dashed #dee2e6;
}
.actions {
margin-top: 30px;
}
</style>