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:
@@ -140,5 +140,64 @@ export default {
|
|||||||
console.error('Calendar getFriendsBirthdays:', error);
|
console.error('Calendar getFriendsBirthdays:', error);
|
||||||
res.status(500).json({ error: error.message || 'Internal server error' });
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/birthdays
|
||||||
|
* Get upcoming birthdays for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetBirthdays(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const birthdays = await calendarService.getUpcomingBirthdays(hashedUserId, limit);
|
||||||
|
res.json(birthdays);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetBirthdays:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/upcoming
|
||||||
|
* Get upcoming events for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetUpcoming(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const events = await calendarService.getUpcomingEvents(hashedUserId, limit);
|
||||||
|
res.json(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetUpcoming:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/mini
|
||||||
|
* Get mini calendar data for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetMiniCalendar(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await calendarService.getMiniCalendarData(hashedUserId);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetMiniCalendar:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,4 +12,9 @@ router.put('/events/:id', authenticate, calendarController.updateEvent);
|
|||||||
router.delete('/events/:id', authenticate, calendarController.deleteEvent);
|
router.delete('/events/:id', authenticate, calendarController.deleteEvent);
|
||||||
router.get('/birthdays', authenticate, calendarController.getFriendsBirthdays);
|
router.get('/birthdays', authenticate, calendarController.getFriendsBirthdays);
|
||||||
|
|
||||||
|
// Widget endpoints
|
||||||
|
router.get('/widget/birthdays', authenticate, calendarController.getWidgetBirthdays);
|
||||||
|
router.get('/widget/upcoming', authenticate, calendarController.getWidgetUpcoming);
|
||||||
|
router.get('/widget/mini', authenticate, calendarController.getWidgetMiniCalendar);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -274,6 +274,216 @@ class CalendarService {
|
|||||||
return age;
|
return age;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming birthdays for widget (sorted by next occurrence)
|
||||||
|
*/
|
||||||
|
async getUpcomingBirthdays(hashedUserId, limit = 10) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAge = await this.getUserAge(user.id);
|
||||||
|
const today = new Date();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
|
||||||
|
// Get all accepted friendships
|
||||||
|
const friendships = await Friendship.findAll({
|
||||||
|
where: {
|
||||||
|
accepted: true,
|
||||||
|
withdrawn: false,
|
||||||
|
denied: false,
|
||||||
|
[Op.or]: [
|
||||||
|
{ user1Id: user.id },
|
||||||
|
{ user2Id: user.id }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const birthdays = [];
|
||||||
|
|
||||||
|
for (const friendship of friendships) {
|
||||||
|
const friendId = friendship.user1Id === user.id ? friendship.user2Id : friendship.user1Id;
|
||||||
|
|
||||||
|
const birthdateParam = await UserParam.findOne({
|
||||||
|
where: { userId: friendId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: UserParamVisibility,
|
||||||
|
as: 'param_visibilities',
|
||||||
|
include: [{
|
||||||
|
model: UserParamVisibilityType,
|
||||||
|
as: 'visibility_type'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!birthdateParam || !birthdateParam.value) continue;
|
||||||
|
|
||||||
|
const visibility = birthdateParam.param_visibilities?.[0]?.visibility_type?.description || 'Invisible';
|
||||||
|
if (!this.isBirthdayVisibleToFriend(visibility, userAge)) continue;
|
||||||
|
|
||||||
|
const friend = await User.findOne({
|
||||||
|
where: { id: friendId },
|
||||||
|
attributes: ['username', 'hashedId']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friend) continue;
|
||||||
|
|
||||||
|
const birthdate = new Date(birthdateParam.value);
|
||||||
|
if (isNaN(birthdate.getTime())) continue;
|
||||||
|
|
||||||
|
// Calculate next birthday
|
||||||
|
let nextBirthday = new Date(currentYear, birthdate.getMonth(), birthdate.getDate());
|
||||||
|
if (nextBirthday < today) {
|
||||||
|
nextBirthday = new Date(currentYear + 1, birthdate.getMonth(), birthdate.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate days until birthday
|
||||||
|
const diffTime = nextBirthday.getTime() - today.getTime();
|
||||||
|
const daysUntil = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Calculate age they will turn
|
||||||
|
const turningAge = nextBirthday.getFullYear() - birthdate.getFullYear();
|
||||||
|
|
||||||
|
birthdays.push({
|
||||||
|
username: friend.username,
|
||||||
|
hashedId: friend.hashedId,
|
||||||
|
date: `${String(birthdate.getMonth() + 1).padStart(2, '0')}-${String(birthdate.getDate()).padStart(2, '0')}`,
|
||||||
|
nextDate: nextBirthday.toISOString().split('T')[0],
|
||||||
|
daysUntil,
|
||||||
|
turningAge
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by days until birthday
|
||||||
|
birthdays.sort((a, b) => a.daysUntil - b.daysUntil);
|
||||||
|
|
||||||
|
return birthdays.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming events for widget
|
||||||
|
*/
|
||||||
|
async getUpcomingEvents(hashedUserId, limit = 10) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const events = await CalendarEvent.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
[Op.or]: [
|
||||||
|
{ startDate: { [Op.gte]: todayStr } },
|
||||||
|
{ endDate: { [Op.gte]: todayStr } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
order: [['startDate', 'ASC'], ['startTime', 'ASC']],
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
titel: e.title,
|
||||||
|
datum: e.startDate,
|
||||||
|
beschreibung: e.description || null,
|
||||||
|
categoryId: e.categoryId,
|
||||||
|
allDay: e.allDay,
|
||||||
|
startTime: e.startTime ? e.startTime.substring(0, 5) : null,
|
||||||
|
endDate: e.endDate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mini calendar data for widget
|
||||||
|
*/
|
||||||
|
async getMiniCalendarData(hashedUserId) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = today.getMonth();
|
||||||
|
|
||||||
|
// Get first and last day of month
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
const startStr = firstDay.toISOString().split('T')[0];
|
||||||
|
const endStr = lastDay.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get user events for this month
|
||||||
|
const events = await CalendarEvent.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
[Op.or]: [
|
||||||
|
{ startDate: { [Op.between]: [startStr, endStr] } },
|
||||||
|
{ endDate: { [Op.between]: [startStr, endStr] } },
|
||||||
|
{
|
||||||
|
[Op.and]: [
|
||||||
|
{ startDate: { [Op.lte]: startStr } },
|
||||||
|
{ endDate: { [Op.gte]: endStr } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get birthdays for this month
|
||||||
|
const birthdays = await this.getFriendsBirthdays(hashedUserId, year);
|
||||||
|
const monthBirthdays = birthdays.filter(b => {
|
||||||
|
const bMonth = parseInt(b.startDate.split('-')[1]);
|
||||||
|
return bMonth === month + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build days with events
|
||||||
|
const daysWithEvents = {};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const start = new Date(event.startDate);
|
||||||
|
const end = event.endDate ? new Date(event.endDate) : start;
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end && d <= lastDay; d.setDate(d.getDate() + 1)) {
|
||||||
|
if (d >= firstDay) {
|
||||||
|
const dayNum = d.getDate();
|
||||||
|
if (!daysWithEvents[dayNum]) {
|
||||||
|
daysWithEvents[dayNum] = { events: 0, birthdays: 0 };
|
||||||
|
}
|
||||||
|
daysWithEvents[dayNum].events++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const birthday of monthBirthdays) {
|
||||||
|
const dayNum = parseInt(birthday.startDate.split('-')[2]);
|
||||||
|
if (!daysWithEvents[dayNum]) {
|
||||||
|
daysWithEvents[dayNum] = { events: 0, birthdays: 0 };
|
||||||
|
}
|
||||||
|
daysWithEvents[dayNum].birthdays++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month: month + 1,
|
||||||
|
today: today.getDate(),
|
||||||
|
firstDayOfWeek: firstDay.getDay() === 0 ? 7 : firstDay.getDay(), // Monday = 1
|
||||||
|
daysInMonth: lastDay.getDate(),
|
||||||
|
daysWithEvents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format event for API response
|
* Format event for API response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import WidgetType from '../models/type/widget_type.js';
|
|||||||
const DEFAULT_WIDGET_TYPES = [
|
const DEFAULT_WIDGET_TYPES = [
|
||||||
{ label: 'Termine', endpoint: '/api/termine', description: 'Bevorstehende Termine', orderId: 1 },
|
{ label: 'Termine', endpoint: '/api/termine', description: 'Bevorstehende Termine', orderId: 1 },
|
||||||
{ label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 },
|
{ label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 },
|
||||||
{ label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 }
|
{ label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 },
|
||||||
|
{ label: 'Geburtstage', endpoint: '/api/calendar/widget/birthdays', description: 'Nächste Geburtstage von Freunden', orderId: 4 },
|
||||||
|
{ label: 'Nächste Termine', endpoint: '/api/calendar/widget/upcoming', description: 'Anstehende Kalendertermine', orderId: 5 },
|
||||||
|
{ label: 'Kalender', endpoint: '/api/calendar/widget/mini', description: 'Mini-Kalenderansicht', orderId: 6 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,18 +25,24 @@ import apiClient from '@/utils/axios.js';
|
|||||||
import FalukantWidget from './widgets/FalukantWidget.vue';
|
import FalukantWidget from './widgets/FalukantWidget.vue';
|
||||||
import NewsWidget from './widgets/NewsWidget.vue';
|
import NewsWidget from './widgets/NewsWidget.vue';
|
||||||
import ListWidget from './widgets/ListWidget.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) {
|
function getWidgetComponent(endpoint) {
|
||||||
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
|
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
|
||||||
const ep = endpoint.toLowerCase();
|
const ep = endpoint.toLowerCase();
|
||||||
if (ep.includes('falukant')) return FalukantWidget;
|
if (ep.includes('falukant')) return FalukantWidget;
|
||||||
if (ep.includes('news')) return NewsWidget;
|
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;
|
return ListWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DashboardWidget',
|
name: 'DashboardWidget',
|
||||||
components: { FalukantWidget, NewsWidget, ListWidget },
|
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget },
|
||||||
props: {
|
props: {
|
||||||
widgetId: { type: String, required: true },
|
widgetId: { type: String, required: true },
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
|
|||||||
122
frontend/src/components/widgets/BirthdayWidget.vue
Normal file
122
frontend/src/components/widgets/BirthdayWidget.vue
Normal 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>
|
||||||
213
frontend/src/components/widgets/MiniCalendarWidget.vue
Normal file
213
frontend/src/components/widgets/MiniCalendarWidget.vue
Normal 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>
|
||||||
154
frontend/src/components/widgets/UpcomingEventsWidget.vue
Normal file
154
frontend/src/components/widgets/UpcomingEventsWidget.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user