Add widget functionality for birthdays, upcoming events, and mini calendar: Implement new API endpoints in calendarController and calendarService to retrieve upcoming birthdays and events, as well as mini calendar data. Update calendarRouter to include widget routes and enhance DashboardWidget to dynamically render new widget components. This update improves user experience by providing quick access to important calendar information.

This commit is contained in:
Torsten Schulz (local)
2026-01-30 15:14:37 +01:00
parent f65d3385ec
commit 7ed284d74b
8 changed files with 774 additions and 2 deletions

View File

@@ -25,18 +25,24 @@ 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 },
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget },
props: {
widgetId: { type: String, required: true },
title: { type: String, required: true },

View File

@@ -0,0 +1,122 @@
<template>
<div v-if="birthdays.length" class="birthday-widget">
<div
v-for="(birthday, i) in birthdays"
:key="i"
:class="['birthday-item', { 'birthday-today': birthday.daysUntil === 0 }]"
>
<div class="birthday-icon">🎂</div>
<div class="birthday-info">
<div class="birthday-name">{{ birthday.username }}</div>
<div class="birthday-date">
<span v-if="birthday.daysUntil === 0" class="birthday-highlight">Heute!</span>
<span v-else-if="birthday.daysUntil === 1">Morgen</span>
<span v-else>{{ formatDate(birthday.nextDate) }}</span>
<span class="birthday-age">(wird {{ birthday.turningAge }})</span>
</div>
</div>
<div v-if="birthday.daysUntil > 1" class="birthday-days">
{{ birthday.daysUntil }} Tage
</div>
</div>
</div>
<div v-else class="birthday-empty">
Keine Geburtstage von Freunden sichtbar
</div>
</template>
<script>
export default {
name: 'BirthdayWidget',
props: {
data: { type: Array, default: () => [] }
},
computed: {
birthdays() {
return Array.isArray(this.data) ? this.data : [];
}
},
methods: {
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short'
});
}
}
};
</script>
<style scoped>
.birthday-widget {
display: flex;
flex-direction: column;
gap: 8px;
}
.birthday-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 6px;
background: #fafafa;
transition: background 0.2s;
}
.birthday-item:hover {
background: #f0f0f0;
}
.birthday-item.birthday-today {
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
border: 1px solid #ffd54f;
}
.birthday-icon {
font-size: 1.5em;
}
.birthday-info {
flex: 1;
min-width: 0;
}
.birthday-name {
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.birthday-date {
font-size: 0.85em;
color: #666;
}
.birthday-highlight {
color: #f57c00;
font-weight: 600;
}
.birthday-age {
margin-left: 4px;
color: #888;
}
.birthday-days {
font-size: 0.8em;
color: #888;
white-space: nowrap;
}
.birthday-empty {
color: #888;
text-align: center;
padding: 1rem;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div v-if="calendarData" class="mini-calendar">
<div class="mini-calendar-header">
{{ monthName }} {{ calendarData.year }}
</div>
<div class="mini-calendar-weekdays">
<span v-for="day in weekdays" :key="day" class="mini-calendar-weekday">{{ day }}</span>
</div>
<div class="mini-calendar-days">
<span
v-for="(day, i) in calendarDays"
:key="i"
:class="getDayClasses(day)"
>
<template v-if="day">
{{ day }}
<span v-if="getDayIndicators(day)" class="day-indicators">
<span v-if="getDayIndicators(day).events" class="indicator event-indicator"></span>
<span v-if="getDayIndicators(day).birthdays" class="indicator birthday-indicator"></span>
</span>
</template>
</span>
</div>
<router-link to="/personal/calendar" class="mini-calendar-link">
Zum Kalender
</router-link>
</div>
<div v-else class="mini-calendar-empty">
Kalender wird geladen...
</div>
</template>
<script>
const MONTH_NAMES = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
export default {
name: 'MiniCalendarWidget',
props: {
data: { type: Object, default: null }
},
data() {
return {
weekdays: ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
};
},
computed: {
calendarData() {
return this.data;
},
monthName() {
if (!this.calendarData) return '';
return MONTH_NAMES[this.calendarData.month - 1];
},
calendarDays() {
if (!this.calendarData) return [];
const days = [];
const { firstDayOfWeek, daysInMonth } = this.calendarData;
// Add empty cells for days before the first day of month
for (let i = 1; i < firstDayOfWeek; i++) {
days.push(null);
}
// Add days of month
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
return days;
}
},
methods: {
getDayClasses(day) {
if (!day) return 'mini-calendar-day empty';
const classes = ['mini-calendar-day'];
if (this.calendarData && day === this.calendarData.today) {
classes.push('today');
}
const indicators = this.getDayIndicators(day);
if (indicators) {
if (indicators.events) classes.push('has-events');
if (indicators.birthdays) classes.push('has-birthdays');
}
return classes.join(' ');
},
getDayIndicators(day) {
if (!day || !this.calendarData || !this.calendarData.daysWithEvents) return null;
return this.calendarData.daysWithEvents[day] || null;
}
}
};
</script>
<style scoped>
.mini-calendar {
font-size: 0.85rem;
}
.mini-calendar-header {
text-align: center;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.mini-calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 4px;
}
.mini-calendar-weekday {
text-align: center;
font-size: 0.75em;
color: #888;
font-weight: 500;
}
.mini-calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.mini-calendar-day {
position: relative;
text-align: center;
padding: 4px 2px;
border-radius: 4px;
cursor: default;
min-height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.mini-calendar-day.empty {
background: transparent;
}
.mini-calendar-day.today {
background: var(--color-primary-orange, #FFB84D);
color: #000;
font-weight: 600;
border-radius: 50%;
}
.mini-calendar-day.has-events:not(.today) {
background: #e3f2fd;
}
.mini-calendar-day.has-birthdays:not(.today) {
background: #fff3e0;
}
.mini-calendar-day.has-events.has-birthdays:not(.today) {
background: linear-gradient(135deg, #e3f2fd 50%, #fff3e0 50%);
}
.day-indicators {
position: absolute;
bottom: 1px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2px;
}
.indicator {
width: 4px;
height: 4px;
border-radius: 50%;
}
.event-indicator {
background: #2196F3;
}
.birthday-indicator {
background: #FF9800;
}
.mini-calendar-link {
display: block;
text-align: center;
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #eee;
color: var(--color-primary-orange, #FFB84D);
text-decoration: none;
font-size: 0.85em;
}
.mini-calendar-link:hover {
text-decoration: underline;
}
.mini-calendar-empty {
color: #888;
text-align: center;
padding: 1rem;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div v-if="events.length" class="upcoming-widget">
<div
v-for="(event, i) in events"
:key="i"
class="upcoming-item"
>
<div
class="upcoming-category"
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
></div>
<div class="upcoming-info">
<div class="upcoming-title">{{ event.titel }}</div>
<div class="upcoming-date">
{{ formatDate(event.datum) }}
<span v-if="event.startTime && !event.allDay" class="upcoming-time">
{{ event.startTime }} Uhr
</span>
<span v-if="event.allDay" class="upcoming-allday">Ganztägig</span>
</div>
<div v-if="event.beschreibung" class="upcoming-desc">{{ event.beschreibung }}</div>
</div>
</div>
</div>
<div v-else class="upcoming-empty">
Keine anstehenden Termine
</div>
</template>
<script>
const CATEGORY_COLORS = {
personal: '#4CAF50',
work: '#2196F3',
family: '#9C27B0',
health: '#F44336',
birthday: '#FF9800',
holiday: '#00BCD4',
reminder: '#795548',
other: '#607D8B'
};
export default {
name: 'UpcomingEventsWidget',
props: {
data: { type: Array, default: () => [] }
},
computed: {
events() {
return Array.isArray(this.data) ? this.data : [];
}
},
methods: {
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (d.toDateString() === today.toDateString()) {
return 'Heute';
}
if (d.toDateString() === tomorrow.toDateString()) {
return 'Morgen';
}
return d.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short'
});
},
getCategoryColor(categoryId) {
return CATEGORY_COLORS[categoryId] || CATEGORY_COLORS.other;
}
}
};
</script>
<style scoped>
.upcoming-widget {
display: flex;
flex-direction: column;
gap: 8px;
}
.upcoming-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border-radius: 6px;
background: #fafafa;
transition: background 0.2s;
}
.upcoming-item:hover {
background: #f0f0f0;
}
.upcoming-category {
width: 4px;
min-height: 40px;
height: 100%;
border-radius: 2px;
flex-shrink: 0;
}
.upcoming-info {
flex: 1;
min-width: 0;
}
.upcoming-title {
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-date {
font-size: 0.85em;
color: #666;
margin-top: 2px;
}
.upcoming-time {
margin-left: 6px;
color: #888;
}
.upcoming-allday {
margin-left: 6px;
color: #888;
font-style: italic;
}
.upcoming-desc {
font-size: 0.8em;
color: #888;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-empty {
color: #888;
text-align: center;
padding: 1rem;
font-style: italic;
}
</style>