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:
@@ -20,6 +20,7 @@ import taxiMapRouter from './routers/taxiMapRouter.js';
|
|||||||
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||||
import termineRouter from './routers/termineRouter.js';
|
import termineRouter from './routers/termineRouter.js';
|
||||||
import vocabRouter from './routers/vocabRouter.js';
|
import vocabRouter from './routers/vocabRouter.js';
|
||||||
|
import dashboardRouter from './routers/dashboardRouter.js';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import './jobs/sessionCleanup.js';
|
import './jobs/sessionCleanup.js';
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ app.use('/api/friendships', friendshipRouter);
|
|||||||
app.use('/api/models', modelsProxyRouter);
|
app.use('/api/models', modelsProxyRouter);
|
||||||
app.use('/api/blog', blogRouter);
|
app.use('/api/blog', blogRouter);
|
||||||
app.use('/api/termine', termineRouter);
|
app.use('/api/termine', termineRouter);
|
||||||
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
|
||||||
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
||||||
// /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung)
|
// /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung)
|
||||||
|
|||||||
39
backend/controllers/dashboardController.js
Normal file
39
backend/controllers/dashboardController.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import dashboardService from '../services/dashboardService.js';
|
||||||
|
|
||||||
|
function getHashedUserId(req) {
|
||||||
|
return req.headers?.userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async getConfig(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = await dashboardService.getConfig(hashedUserId);
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard getConfig:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setConfig(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const config = req.body;
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid config' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await dashboardService.setConfig(hashedUserId, config);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard setConfig:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import ChatUser from './chat/user.js';
|
|||||||
import Room from './chat/room.js';
|
import Room from './chat/room.js';
|
||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
import UserParamType from './type/user_param.js';
|
import UserParamType from './type/user_param.js';
|
||||||
import UserRightType from './type/user_right.js';
|
import UserRightType from './type/user_right.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
@@ -166,6 +167,9 @@ export default function setupAssociations() {
|
|||||||
User.hasMany(UserParam, { foreignKey: 'userId', as: 'user_params' });
|
User.hasMany(UserParam, { foreignKey: 'userId', as: 'user_params' });
|
||||||
UserParam.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
UserParam.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
|
User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' });
|
||||||
|
UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
||||||
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
||||||
|
|
||||||
|
|||||||
24
backend/models/community/user_dashboard.js
Normal file
24
backend/models/community/user_dashboard.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import User from './user.js';
|
||||||
|
|
||||||
|
const UserDashboard = sequelize.define('user_dashboard', {
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
references: { model: User, key: 'id' }
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: { widgets: [] }
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'user_dashboard',
|
||||||
|
schema: 'community',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export default UserDashboard;
|
||||||
@@ -6,6 +6,7 @@ import UserParamType from './type/user_param.js';
|
|||||||
import UserRightType from './type/user_right.js';
|
import UserRightType from './type/user_right.js';
|
||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
import Login from './logs/login.js';
|
import Login from './logs/login.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
import InterestType from './type/interest.js';
|
import InterestType from './type/interest.js';
|
||||||
@@ -152,6 +153,7 @@ const models = {
|
|||||||
UserRightType,
|
UserRightType,
|
||||||
User,
|
User,
|
||||||
UserParam,
|
UserParam,
|
||||||
|
UserDashboard,
|
||||||
Login,
|
Login,
|
||||||
UserRight,
|
UserRight,
|
||||||
InterestType,
|
InterestType,
|
||||||
|
|||||||
10
backend/routers/dashboardRouter.js
Normal file
10
backend/routers/dashboardRouter.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import dashboardController from '../controllers/dashboardController.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/config', authenticate, dashboardController.getConfig.bind(dashboardController));
|
||||||
|
router.put('/config', authenticate, dashboardController.setConfig.bind(dashboardController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
37
backend/services/dashboardService.js
Normal file
37
backend/services/dashboardService.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import BaseService from './BaseService.js';
|
||||||
|
import UserDashboard from '../models/community/user_dashboard.js';
|
||||||
|
|
||||||
|
class DashboardService extends BaseService {
|
||||||
|
/**
|
||||||
|
* @param {string} hashedUserId
|
||||||
|
* @returns {Promise<{ widgets: Array<{ id: string, title: string, endpoint: string }> }>}
|
||||||
|
*/
|
||||||
|
async getConfig(hashedUserId) {
|
||||||
|
const user = await this.getUserByHashedId(hashedUserId);
|
||||||
|
const row = await UserDashboard.findOne({ where: { userId: user.id } });
|
||||||
|
const config = row?.config ?? { widgets: [] };
|
||||||
|
if (!Array.isArray(config.widgets)) config.widgets = [];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} hashedUserId
|
||||||
|
* @param {{ widgets: Array<{ id: string, title: string, endpoint: string }> }} config
|
||||||
|
*/
|
||||||
|
async setConfig(hashedUserId, config) {
|
||||||
|
const user = await this.getUserByHashedId(hashedUserId);
|
||||||
|
const widgets = Array.isArray(config?.widgets) ? config.widgets : [];
|
||||||
|
const sanitized = widgets.map(w => ({
|
||||||
|
id: String(w?.id ?? ''),
|
||||||
|
title: String(w?.title ?? ''),
|
||||||
|
endpoint: String(w?.endpoint ?? '')
|
||||||
|
})).filter(w => w.id && (w.title || w.endpoint));
|
||||||
|
await UserDashboard.upsert({
|
||||||
|
userId: user.id,
|
||||||
|
config: { widgets: sanitized }
|
||||||
|
}, { conflictFields: ['userId'] });
|
||||||
|
return { widgets: sanitized };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new DashboardService();
|
||||||
218
frontend/src/components/DashboardWidget.vue
Normal file
218
frontend/src/components/DashboardWidget.vue
Normal 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>
|
||||||
@@ -1,67 +1,368 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-logged-in">
|
<div class="home-logged-in">
|
||||||
<h1>Willkommen zurück!</h1>
|
<header class="dashboard-header">
|
||||||
<p>Schön, dass du wieder da bist.</p>
|
<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>
|
||||||
|
|
||||||
<TermineWidget />
|
<div
|
||||||
|
v-if="loadError"
|
||||||
<div class="actions">
|
class="dashboard-message dashboard-error"
|
||||||
<button @click="handleLogout" class="logout-btn">Logout</button>
|
>
|
||||||
</div>
|
{{ loadError }}
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapActions } from 'vuex';
|
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 {
|
export default {
|
||||||
name: 'HomeLoggedInView',
|
name: 'HomeLoggedInView',
|
||||||
components: {
|
components: { DashboardWidget },
|
||||||
TermineWidget
|
data() {
|
||||||
|
return {
|
||||||
|
widgets: [],
|
||||||
|
loading: true,
|
||||||
|
loadError: null,
|
||||||
|
editMode: false,
|
||||||
|
draggedIndex: null,
|
||||||
|
dragOverIndex: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadConfig();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['logout']),
|
||||||
|
handleLogout() {
|
||||||
|
this.logout();
|
||||||
},
|
},
|
||||||
methods: {
|
async loadConfig() {
|
||||||
...mapActions(['logout']),
|
this.loading = true;
|
||||||
handleLogout() {
|
this.loadError = null;
|
||||||
this.logout();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home-logged-in {
|
.home-logged-in {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-logged-in h1 {
|
.dashboard-header {
|
||||||
color: #333;
|
margin-bottom: 24px;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-logged-in p {
|
.dashboard-header h1 {
|
||||||
color: #666;
|
color: #333;
|
||||||
margin-bottom: 20px;
|
margin: 0 0 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.dashboard-subtitle {
|
||||||
margin-top: 30px;
|
color: #666;
|
||||||
text-align: center;
|
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 {
|
.logout-btn {
|
||||||
background: #dc3545;
|
margin-left: auto;
|
||||||
color: white;
|
background: #dc3545;
|
||||||
border: none;
|
color: white;
|
||||||
padding: 10px 20px;
|
border: none;
|
||||||
border-radius: 4px;
|
padding: 8px 16px;
|
||||||
cursor: pointer;
|
border-radius: 6px;
|
||||||
font-size: 1em;
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
background: #c82333;
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
Reference in New Issue
Block a user