feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:21:30 +02:00
parent 9be5f50ede
commit 004801b1a6
33 changed files with 2715 additions and 632 deletions

View File

@@ -29,39 +29,78 @@
</div>
</section>
<section v-if="currentClub" class="training-cancellation-panel">
<div>
<h3>Training fällt aus</h3>
<p>Hier eingetragene Tage blenden die regelmäßigen Trainingszeiten aus.</p>
<details v-if="currentClub" class="calendar-options" :open="optionsOpen">
<summary class="calendar-options-summary" @click.prevent="optionsOpen = !optionsOpen">
<span>Optionen</span>
<small>Ausfälle & eigene Termine</small>
</summary>
<div class="calendar-options-body">
<section class="training-cancellation-panel">
<div>
<h3>Training fällt aus</h3>
<p>Blendet regelmäßige Trainingszeiten aus.</p>
</div>
<form class="training-cancellation-form" @submit.prevent="saveTrainingCancellation">
<label>
<span>Datum</span>
<input v-model="cancellationForm.startDate" type="date" required />
</label>
<label>
<span>Bis optional</span>
<input v-model="cancellationForm.endDate" type="date" />
</label>
<input v-model="cancellationForm.reason" type="text" placeholder="Grund (optional)" />
<button type="submit" :disabled="cancellationSaving">
{{ cancellationSaving ? 'Speichern...' : 'Eintragen' }}
</button>
</form>
<div v-if="visibleTrainingCancellations.length" class="training-cancellation-list">
<button
v-for="cancellation in visibleTrainingCancellations"
:key="`cancel-${cancellation.cancellationId}`"
type="button"
class="training-cancellation-item"
@click="deleteTrainingCancellation(cancellation)"
title="Löschen"
>
<strong>{{ formatShortDate(cancellation.date) }}</strong>
<span>{{ cancellation.title }}</span>
<small>Löschen</small>
</button>
</div>
</section>
<section class="custom-event-panel">
<div>
<h3>Eigene Termine</h3>
<p>Kreistage, Sitzungen, interne Meetings, ...</p>
</div>
<form class="custom-event-form" @submit.prevent="saveCustomEvent">
<input v-model="customEventForm.title" type="text" placeholder="Titel" required />
<input v-model="customEventForm.startDate" type="date" required />
<input v-model="customEventForm.endDate" type="date" />
<input v-model="customEventForm.category" type="text" placeholder="Kategorie (optional)" />
<button type="submit" :disabled="customEventSaving">
{{ customEventSaving ? 'Speichern...' : 'Anlegen' }}
</button>
</form>
<div v-if="visibleCustomEvents.length" class="custom-event-list">
<button
v-for="event in visibleCustomEvents"
:key="`ce-${event.customEventId}`"
type="button"
class="custom-event-item"
@click="deleteCustomEvent(event)"
title="Löschen"
>
<strong>{{ formatEventDate(event) }}</strong>
<span>{{ event.title }}</span>
<small>Löschen</small>
</button>
</div>
</section>
</div>
<form class="training-cancellation-form" @submit.prevent="saveTrainingCancellation">
<label>
<span>Datum</span>
<input v-model="cancellationForm.startDate" type="date" required />
</label>
<label>
<span>Bis optional</span>
<input v-model="cancellationForm.endDate" type="date" />
</label>
<input v-model="cancellationForm.reason" type="text" placeholder="Grund, z.B. Halle gesperrt" />
<button type="submit" :disabled="cancellationSaving">
{{ cancellationSaving ? 'Speichern...' : 'Eintragen' }}
</button>
</form>
<div v-if="visibleTrainingCancellations.length" class="training-cancellation-list">
<button
v-for="cancellation in visibleTrainingCancellations"
:key="`cancel-${cancellation.cancellationId}`"
type="button"
class="training-cancellation-item"
@click="deleteTrainingCancellation(cancellation)"
>
<strong>{{ formatShortDate(cancellation.date) }}</strong>
<span>{{ cancellation.title }}</span>
<small>Löschen</small>
</button>
</div>
</section>
</details>
<section v-if="sourceWarnings.length" class="calendar-state calendar-state-warning">
{{ sourceWarnings.join(' · ') }}
@@ -116,17 +155,34 @@
<span class="agenda-time">{{ event.time }}</span>
</button>
</section>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
:confirm-text="confirmDialog.confirmText"
:cancel-text="confirmDialog.cancelText"
:show-cancel="confirmDialog.showCancel"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
import ConfirmDialog from '../components/ConfirmDialog.vue';
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export default {
name: 'CalendarView',
components: {
ConfirmDialog,
},
data() {
const today = new Date();
return {
@@ -142,18 +198,39 @@ export default {
match: true,
holiday: true,
schoolHoliday: true,
trainingCancellation: true
trainingCancellation: true,
customEvent: true
},
cancellationSaving: false,
cancellationForm: {
startDate: '',
endDate: '',
reason: ''
}
},
customEventSaving: false,
customEventForm: {
title: '',
startDate: '',
endDate: '',
category: ''
},
optionsOpen: false,
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
confirmText: '',
cancelText: '',
showCancel: true,
resolveCallback: null,
},
plannedTrainingByDateKey: {},
};
},
computed: {
...mapGetters(['currentClub']),
...mapGetters(['currentClub', 'currentClubName']),
weekdays() {
return WEEKDAYS;
},
@@ -165,7 +242,8 @@ export default {
{ key: 'match', label: 'Punktspiel' },
{ key: 'holiday', label: 'Feiertag' },
{ key: 'schoolHoliday', label: 'Ferien' },
{ key: 'trainingCancellation', label: 'Ausfall' }
{ key: 'trainingCancellation', label: 'Ausfall' },
{ key: 'customEvent', label: 'Termin' }
];
},
monthLabel() {
@@ -184,13 +262,14 @@ export default {
const monthEnd = new Date(year, month + 1, 0);
return this.visibleEventPool
.filter(event => this.isEventInRange(event, monthStart, monthEnd))
.filter(event => this.shouldShowEventOnDate(event, this.toDateKey(event.date)))
.sort((a, b) => a.startsAt - b.startsAt);
},
eventCounts() {
return this.events.reduce((counts, event) => {
counts[event.type] = (counts[event.type] || 0) + 1;
return counts;
}, { training: 0, tournament: 0, officialTournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 });
}, { training: 0, tournament: 0, officialTournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0, customEvent: 0 });
},
visibleTrainingCancellations() {
const month = this.cursor.getMonth();
@@ -200,6 +279,14 @@ export default {
.filter(event => event.date.getFullYear() === year && event.date.getMonth() === month)
.sort((a, b) => a.startsAt - b.startsAt);
},
visibleCustomEvents() {
const month = this.cursor.getMonth();
const year = this.cursor.getFullYear();
return this.events
.filter(event => event.type === 'customEvent')
.filter(event => event.date.getFullYear() === year && event.date.getMonth() === month)
.sort((a, b) => a.startsAt - b.startsAt);
},
sourceWarnings() {
return this.sourceErrors.map(source => `${source} konnte nicht geladen werden`);
},
@@ -223,6 +310,7 @@ export default {
isToday: key === todayKey,
events: this.visibleEventPool
.filter(event => this.isEventOnDate(event, date))
.filter(event => this.shouldShowEventOnDate(event, key))
.sort((a, b) => a.startsAt - b.startsAt)
};
});
@@ -240,6 +328,32 @@ export default {
}
},
methods: {
async showConfirm(title, message, details = '', type = 'info', options = {}) {
return new Promise((resolve) => {
this.confirmDialog = {
isOpen: true,
title,
message,
details,
type,
confirmText: options.confirmText || '',
cancelText: options.cancelText || '',
showCancel: options.showCancel !== undefined ? !!options.showCancel : true,
resolveCallback: resolve,
};
});
},
handleConfirmResult(confirmed) {
if (this.confirmDialog.resolveCallback) {
this.confirmDialog.resolveCallback(confirmed);
}
this.confirmDialog.isOpen = false;
this.confirmDialog.resolveCallback = null;
},
isOurTeam(teamName) {
if (!teamName || !this.currentClubName) return false;
return String(teamName).startsWith(this.currentClubName);
},
async loadCalendarEvents() {
if (!this.currentClub) {
this.events = [];
@@ -254,6 +368,7 @@ export default {
this.loadSource('Trainingstage', () => this.loadTrainingEvents()),
this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()),
this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()),
this.loadSource('Eigene Termine', () => this.loadCustomEvents()),
this.loadSource('Turniere', () => this.loadTournamentEvents()),
this.loadSource('Turnierteilnahmen', () => this.loadOfficialTournamentEvents()),
this.loadSource('Punktspiele', () => this.loadMatchEvents()),
@@ -264,6 +379,14 @@ export default {
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value.events)
.filter(event => event.date && !Number.isNaN(event.date.getTime()));
this.plannedTrainingByDateKey = loadedEvents
.filter(event => event.type === 'training')
.reduce((map, event) => {
map[this.toDateKey(event.date)] = true;
return map;
}, {});
const cancellationDates = new Set(
loadedEvents
.filter(event => event.type === 'trainingCancellation')
@@ -470,6 +593,50 @@ export default {
this.ensureSuccess(response, 'Trainingsausfälle');
await this.loadCalendarEvents();
},
async loadCustomEvents() {
const response = await apiClient.get(`/calendar-events/${this.currentClub}`, {
params: { year: this.displayedYear },
});
this.ensureSuccess(response, 'Eigene Termine');
return (response.data || []).map(event => {
const date = this.parseDate(event.startDate);
const endDate = this.parseDate(event.endDate || event.startDate);
return {
id: `custom-event-${event.id}`,
customEventId: event.id,
type: 'customEvent',
date,
endDate,
startsAt: this.combineDateTime(date),
time: '',
title: event.title,
subtitle: event.category || 'Eigener Termin',
};
});
},
async saveCustomEvent() {
if (!this.currentClub || !this.customEventForm.title || !this.customEventForm.startDate) return;
this.customEventSaving = true;
try {
const response = await apiClient.post(`/calendar-events/${this.currentClub}`, {
title: this.customEventForm.title,
startDate: this.customEventForm.startDate,
endDate: this.customEventForm.endDate || this.customEventForm.startDate,
category: this.customEventForm.category || null,
});
this.ensureSuccess(response, 'Eigene Termine');
this.customEventForm = { title: '', startDate: '', endDate: '', category: '' };
await this.loadCalendarEvents();
} finally {
this.customEventSaving = false;
}
},
async deleteCustomEvent(event) {
if (!this.currentClub || !event?.customEventId) return;
const response = await apiClient.delete(`/calendar-events/${this.currentClub}/${event.customEventId}`);
this.ensureSuccess(response, 'Eigene Termine');
await this.loadCalendarEvents();
},
async loadTournamentEvents() {
const response = await apiClient.get(`/tournament/${this.currentClub}`);
this.ensureSuccess(response, 'Turniere');
@@ -490,7 +657,9 @@ export default {
async loadMatchEvents() {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
this.ensureSuccess(response, 'Punktspiele');
return (response.data || []).map(match => {
return (response.data || [])
.filter(match => this.isOurTeam(match.homeTeam?.name) || this.isOurTeam(match.guestTeam?.name))
.map(match => {
const date = this.parseDate(match.date);
const home = match.homeTeam?.name || 'Heim';
const guest = match.guestTeam?.name || 'Gast';
@@ -603,6 +772,10 @@ export default {
const eventEnd = this.startOfDay(event.endDate || event.date);
return eventStart <= this.startOfDay(rangeEnd) && eventEnd >= this.startOfDay(rangeStart);
},
shouldShowEventOnDate(event, dateKey) {
if (event.type !== 'trainingCancellation') return true;
return Boolean(this.plannedTrainingByDateKey[dateKey]);
},
startOfDay(date) {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
@@ -644,9 +817,51 @@ export default {
this.cursor = new Date(today.getFullYear(), today.getMonth(), 1);
},
openEvent(event) {
if (event?.type === 'holiday' || event?.type === 'schoolHoliday') {
this.offerTrainingCancellationForHoliday(event);
return;
}
if (event.route) {
this.$router.push(event.route);
}
},
async offerTrainingCancellationForHoliday(event) {
if (!this.currentClub) return;
const startDateKey = this.toDateKey(event.date);
const endDateKey = this.toDateKey(event.endDate || event.date);
const isRange = startDateKey !== endDateKey;
const shouldCreate = await this.showConfirm(
'Trainingsausfall',
'Training als Ausfall markieren?',
'',
'warning',
{ confirmText: 'Ja', cancelText: 'Nein' }
);
if (!shouldCreate) return;
let useRange = false;
if (isRange) {
useRange = await this.showConfirm(
'Zeitraum',
`Gilt der Ausfall für den gesamten Zeitraum (${startDateKey} bis ${endDateKey})?`,
'',
'info',
{ confirmText: 'Zeitraum', cancelText: 'Nur Tag' }
);
}
const reason = `${event.title}${event.subtitle ? ` (${event.subtitle})` : ''}`.trim();
await this.createTrainingCancellation(startDateKey, useRange ? endDateKey : startDateKey, reason);
await this.loadCalendarEvents();
},
async createTrainingCancellation(startDate, endDate, reason) {
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
startDate,
endDate,
reason
});
this.ensureSuccess(response, 'Trainingsausfälle');
}
}
};
@@ -662,6 +877,7 @@ export default {
.calendar-header,
.calendar-toolbar,
.training-cancellation-panel,
.custom-event-panel,
.calendar-agenda {
border: 1px solid #dfe7e2;
border-radius: 10px;
@@ -731,7 +947,7 @@ export default {
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.85rem 1rem;
padding: 0.65rem 1rem;
}
.legend-item {
@@ -741,13 +957,50 @@ export default {
border: 1px solid #d6e2dc;
border-radius: 999px;
background: #f8fbf9;
font-size: 0.84rem;
font-size: 0.8rem;
font-weight: 700;
color: #40524b;
cursor: pointer;
padding: 0.35rem 0.65rem;
}
.calendar-options {
border: 1px solid #dfe7e2;
border-radius: 10px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.calendar-options-summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
cursor: pointer;
padding: 0.75rem 1rem;
background: #f8fbf9;
border-bottom: 1px solid #edf1ee;
user-select: none;
}
.calendar-options-summary span {
font-weight: 900;
color: #14251f;
}
.calendar-options-summary small {
color: #607169;
font-weight: 700;
}
.calendar-options-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
}
.legend-item.inactive {
opacity: 0.45;
}
@@ -766,12 +1019,76 @@ export default {
.legend-holiday::before { background: #dc2626; }
.legend-schoolHoliday::before { background: #7c3aed; }
.legend-trainingCancellation::before { background: #64748b; }
.legend-customEvent::before { background: #0ea5e9; }
.custom-event-panel {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 0;
}
.custom-event-panel h3 {
margin: 0;
color: #14251f;
}
.custom-event-panel p {
margin: 0.25rem 0 0;
color: #607169;
font-size: 0.9rem;
}
.custom-event-form {
display: grid;
grid-template-columns: minmax(14rem, 1fr) 10rem 10rem 12rem auto;
gap: 0.5rem;
}
.custom-event-form input,
.custom-event-form button {
border: 1px solid #cfdad4;
border-radius: 8px;
min-height: 2.4rem;
padding: 0 0.75rem;
}
.custom-event-form button {
background: #0ea5e9;
color: #fff;
cursor: pointer;
font-weight: 800;
}
.custom-event-list {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 0.5rem;
}
.custom-event-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #bae6fd;
border-radius: 999px;
background: #f0f9ff;
color: #0c4a6e;
cursor: pointer;
padding: 0.35rem 0.65rem;
}
.custom-event-item small {
color: #991b1b;
font-weight: 800;
}
.training-cancellation-panel {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(18rem, 2fr);
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
padding: 0;
}
.training-cancellation-panel h3 {
@@ -931,6 +1248,7 @@ export default {
.event-holiday { background: #fee2e2; border-left: 4px solid #dc2626; }
.event-schoolHoliday { background: #f3e8ff; border-left: 4px solid #7c3aed; }
.event-trainingCancellation { background: #f1f5f9; border-left: 4px solid #64748b; }
.event-customEvent { background: #f0f9ff; border-left: 4px solid #0ea5e9; }
.event-time {
font-size: 0.72rem;
@@ -1008,16 +1326,25 @@ export default {
@media (max-width: 900px) {
.calendar-header,
.calendar-toolbar,
.training-cancellation-panel {
.training-cancellation-panel,
.custom-event-panel {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.calendar-options-body {
grid-template-columns: 1fr;
}
.training-cancellation-form {
grid-template-columns: 1fr;
}
.custom-event-form {
grid-template-columns: 1fr;
}
.calendar-grid {
display: flex;
flex-direction: column;