feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data. - Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs. - Enhanced the ClubService to handle normalization of country and state codes during updates. - Added new routes and middleware to support the training cancellation feature and calendar integration in the backend. - Updated frontend navigation to include a calendar link, improving user access to scheduling features.
This commit is contained in:
@@ -85,6 +85,10 @@
|
||||
<span class="nav-icon">📝</span>
|
||||
{{ $t('navigation.diary') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('diary', 'read') || hasPermission('schedule', 'read') || hasPermission('tournaments', 'read')" to="/calendar" class="nav-link" title="Kalender">
|
||||
<span class="nav-icon">📆</span>
|
||||
Kalender
|
||||
</router-link>
|
||||
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
|
||||
<span class="nav-icon">⏳</span>
|
||||
{{ $t('navigation.approvals') }}
|
||||
|
||||
@@ -16,6 +16,7 @@ const CreateClub = () => import('./views/CreateClub.vue');
|
||||
const ClubView = () => import('./views/ClubView.vue');
|
||||
const MembersView = () => import('./views/MembersView.vue');
|
||||
const DiaryView = () => import('./views/DiaryView.vue');
|
||||
const CalendarView = () => import('./views/CalendarView.vue');
|
||||
const PendingApprovalsView = () => import('./views/PendingApprovalsView.vue');
|
||||
const ScheduleView = () => import('./views/ScheduleView.vue');
|
||||
const TournamentsView = () => import('./views/TournamentsView.vue');
|
||||
@@ -51,6 +52,7 @@ const routes = [
|
||||
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView },
|
||||
{ path: '/members', name: 'members', component: MembersView },
|
||||
{ path: '/diary', name: 'diary', component: DiaryView },
|
||||
{ path: '/calendar', name: 'calendar', component: CalendarView },
|
||||
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
|
||||
{ path: '/schedule', name: 'schedule', component: ScheduleView},
|
||||
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
|
||||
|
||||
@@ -79,6 +79,7 @@ const NOINDEX_PREFIXES = [
|
||||
'/showclub',
|
||||
'/members',
|
||||
'/diary',
|
||||
'/calendar',
|
||||
'/pending-approvals',
|
||||
'/schedule',
|
||||
'/tournaments',
|
||||
|
||||
945
frontend/src/views/CalendarView.vue
Normal file
945
frontend/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,945 @@
|
||||
<template>
|
||||
<div class="calendar-page">
|
||||
<header class="calendar-header">
|
||||
<div>
|
||||
<span class="calendar-eyebrow">Vereinskalender</span>
|
||||
<h2>Kalender</h2>
|
||||
<p>Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.</p>
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<button type="button" class="calendar-nav-button" @click="goToPreviousMonth">‹</button>
|
||||
<button type="button" class="calendar-today-button" @click="goToToday">Heute</button>
|
||||
<button type="button" class="calendar-nav-button" @click="goToNextMonth">›</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="calendar-toolbar">
|
||||
<strong>{{ monthLabel }}</strong>
|
||||
<div class="calendar-legend">
|
||||
<button
|
||||
v-for="type in eventTypes"
|
||||
:key="type.key"
|
||||
type="button"
|
||||
class="legend-item"
|
||||
:class="[`legend-${type.key}`, { inactive: !activeTypes[type.key] }]"
|
||||
@click="toggleType(type.key)"
|
||||
>
|
||||
{{ type.label }} <span>{{ eventCounts[type.key] }}</span>
|
||||
</button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<section v-if="sourceWarnings.length" class="calendar-state calendar-state-warning">
|
||||
{{ sourceWarnings.join(' · ') }}
|
||||
</section>
|
||||
<section v-if="loading" class="calendar-state">Kalenderdaten werden geladen...</section>
|
||||
<section v-else-if="error" class="calendar-state calendar-state-error">{{ error }}</section>
|
||||
|
||||
<section v-if="!currentClub" class="calendar-state">Bitte zuerst einen Verein auswählen.</section>
|
||||
|
||||
<section v-else class="calendar-grid" aria-label="Kalender">
|
||||
<div v-for="day in weekdays" :key="day" class="calendar-weekday">{{ day }}</div>
|
||||
<article
|
||||
v-for="day in calendarDays"
|
||||
:key="day.key"
|
||||
class="calendar-day"
|
||||
:class="{ 'outside-month': !day.isCurrentMonth, today: day.isToday }"
|
||||
>
|
||||
<div class="calendar-day-number">{{ day.dayNumber }}</div>
|
||||
<div class="calendar-events">
|
||||
<button
|
||||
v-for="event in day.events"
|
||||
:key="event.id"
|
||||
type="button"
|
||||
class="calendar-event"
|
||||
:class="`event-${event.type}`"
|
||||
@click="openEvent(event)"
|
||||
>
|
||||
<span class="event-time" v-if="event.time">{{ event.time }}</span>
|
||||
<span class="event-title">{{ event.title }}</span>
|
||||
<span class="event-subtitle" v-if="event.subtitle">{{ event.subtitle }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="calendar-agenda">
|
||||
<h3>Termine im Monat</h3>
|
||||
<p v-if="visibleEvents.length === 0" class="agenda-empty">Keine Termine in diesem Monat.</p>
|
||||
<button
|
||||
v-for="event in visibleEvents"
|
||||
:key="`agenda-${event.id}`"
|
||||
type="button"
|
||||
class="agenda-item"
|
||||
:class="`agenda-${event.type}`"
|
||||
@click="openEvent(event)"
|
||||
>
|
||||
<span class="agenda-date">{{ formatEventDate(event) }}</span>
|
||||
<span class="agenda-main">
|
||||
<strong>{{ event.title }}</strong>
|
||||
<small>{{ event.subtitle }}</small>
|
||||
</span>
|
||||
<span class="agenda-time">{{ event.time }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient';
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
export default {
|
||||
name: 'CalendarView',
|
||||
data() {
|
||||
const today = new Date();
|
||||
return {
|
||||
cursor: new Date(today.getFullYear(), today.getMonth(), 1),
|
||||
events: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
sourceErrors: [],
|
||||
activeTypes: {
|
||||
training: true,
|
||||
tournament: true,
|
||||
match: true,
|
||||
holiday: true,
|
||||
schoolHoliday: true,
|
||||
trainingCancellation: true
|
||||
},
|
||||
cancellationSaving: false,
|
||||
cancellationForm: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
reason: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
weekdays() {
|
||||
return WEEKDAYS;
|
||||
},
|
||||
eventTypes() {
|
||||
return [
|
||||
{ key: 'training', label: 'Training' },
|
||||
{ key: 'tournament', label: 'Turnier' },
|
||||
{ key: 'match', label: 'Punktspiel' },
|
||||
{ key: 'holiday', label: 'Feiertag' },
|
||||
{ key: 'schoolHoliday', label: 'Ferien' },
|
||||
{ key: 'trainingCancellation', label: 'Ausfall' }
|
||||
];
|
||||
},
|
||||
monthLabel() {
|
||||
return this.cursor.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
},
|
||||
displayedYear() {
|
||||
return this.cursor.getFullYear();
|
||||
},
|
||||
visibleEventPool() {
|
||||
return this.events.filter(event => this.activeTypes[event.type] !== false);
|
||||
},
|
||||
visibleEvents() {
|
||||
const month = this.cursor.getMonth();
|
||||
const year = this.cursor.getFullYear();
|
||||
const monthStart = new Date(year, month, 1);
|
||||
const monthEnd = new Date(year, month + 1, 0);
|
||||
return this.visibleEventPool
|
||||
.filter(event => this.isEventInRange(event, monthStart, monthEnd))
|
||||
.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, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 });
|
||||
},
|
||||
visibleTrainingCancellations() {
|
||||
const month = this.cursor.getMonth();
|
||||
const year = this.cursor.getFullYear();
|
||||
return this.events
|
||||
.filter(event => event.type === 'trainingCancellation')
|
||||
.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`);
|
||||
},
|
||||
calendarDays() {
|
||||
const year = this.cursor.getFullYear();
|
||||
const month = this.cursor.getMonth();
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const firstWeekday = (firstOfMonth.getDay() + 6) % 7;
|
||||
const gridStart = new Date(year, month, 1 - firstWeekday);
|
||||
const todayKey = this.toDateKey(new Date());
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(gridStart);
|
||||
date.setDate(gridStart.getDate() + index);
|
||||
const key = this.toDateKey(date);
|
||||
return {
|
||||
key,
|
||||
date,
|
||||
dayNumber: date.getDate(),
|
||||
isCurrentMonth: date.getMonth() === month,
|
||||
isToday: key === todayKey,
|
||||
events: this.visibleEventPool
|
||||
.filter(event => this.isEventOnDate(event, date))
|
||||
.sort((a, b) => a.startsAt - b.startsAt)
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentClub: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadCalendarEvents();
|
||||
}
|
||||
},
|
||||
displayedYear() {
|
||||
this.loadCalendarEvents();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadCalendarEvents() {
|
||||
if (!this.currentClub) {
|
||||
this.events = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.sourceErrors = [];
|
||||
|
||||
const sources = await Promise.allSettled([
|
||||
this.loadSource('Trainingstage', () => this.loadTrainingEvents()),
|
||||
this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()),
|
||||
this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()),
|
||||
this.loadSource('Turniere', () => this.loadTournamentEvents()),
|
||||
this.loadSource('Punktspiele', () => this.loadMatchEvents()),
|
||||
this.loadSource('Ferien/Feiertage', () => this.loadHolidayEvents())
|
||||
]);
|
||||
|
||||
const loadedEvents = sources
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.flatMap(result => result.value.events)
|
||||
.filter(event => event.date && !Number.isNaN(event.date.getTime()));
|
||||
const cancellationDates = new Set(
|
||||
loadedEvents
|
||||
.filter(event => event.type === 'trainingCancellation')
|
||||
.flatMap(event => this.getDateKeysForRange(event.date, event.endDate || event.date))
|
||||
);
|
||||
this.events = loadedEvents.filter(event => (
|
||||
!event.isRecurringTraining || !cancellationDates.has(this.toDateKey(event.date))
|
||||
));
|
||||
this.sourceErrors = sources
|
||||
.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason?.source)
|
||||
.filter(Boolean);
|
||||
|
||||
if (sources.every(result => result.status === 'rejected')) {
|
||||
this.error = 'Kalenderdaten konnten nicht geladen werden.';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
async loadSource(source, loader) {
|
||||
try {
|
||||
const events = await loader();
|
||||
return { source, events };
|
||||
} catch (error) {
|
||||
error.source = error.source || source;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadTrainingEvents() {
|
||||
const response = await apiClient.get(`/diary/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Trainingstage');
|
||||
return (response.data || []).map(entry => {
|
||||
const date = this.parseDate(entry.date);
|
||||
return {
|
||||
id: `training-${entry.id}`,
|
||||
type: 'training',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date, entry.trainingStart),
|
||||
time: this.formatTimeRange(entry.trainingStart, entry.trainingEnd),
|
||||
title: 'Training',
|
||||
subtitle: entry.diaryTags?.map(tag => tag.name).join(', ') || '',
|
||||
route: '/diary'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadRecurringTrainingEvents() {
|
||||
const response = await apiClient.get(`/training-times/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Trainingszeiten');
|
||||
const groups = Array.isArray(response.data) ? response.data : [];
|
||||
return groups.flatMap(group => this.mapGroupTrainingTimesToEvents(group));
|
||||
},
|
||||
mapGroupTrainingTimesToEvents(group) {
|
||||
const trainingTimes = Array.isArray(group?.trainingTimes) ? group.trainingTimes : [];
|
||||
return trainingTimes.flatMap(time => this.createRecurringTrainingEvents(group, time));
|
||||
},
|
||||
createRecurringTrainingEvents(group, time) {
|
||||
const weekday = Number(time?.weekday);
|
||||
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6 || !time?.startTime) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = [];
|
||||
const date = new Date(this.displayedYear, 0, 1);
|
||||
const daysUntilWeekday = (weekday - date.getDay() + 7) % 7;
|
||||
date.setDate(date.getDate() + daysUntilWeekday);
|
||||
|
||||
while (date.getFullYear() === this.displayedYear) {
|
||||
const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const dateKey = this.toDateKey(eventDate);
|
||||
events.push({
|
||||
id: `training-time-${group.id}-${time.id}-${dateKey}`,
|
||||
type: 'training',
|
||||
date: eventDate,
|
||||
startsAt: this.combineDateTime(eventDate, time.startTime),
|
||||
time: this.formatTimeRange(time.startTime, time.endTime),
|
||||
title: group?.name || 'Training',
|
||||
subtitle: 'Regelmäßige Trainingszeit',
|
||||
route: '/diary',
|
||||
isRecurringTraining: true
|
||||
});
|
||||
date.setDate(date.getDate() + 7);
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
async loadTrainingCancellationEvents() {
|
||||
const response = await apiClient.get(`/training-cancellations/${this.currentClub}`, {
|
||||
params: { year: this.displayedYear }
|
||||
});
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
return (response.data || []).map(cancellation => {
|
||||
const date = this.parseDate(cancellation.startDate || cancellation.date);
|
||||
const endDate = this.parseDate(cancellation.endDate || cancellation.startDate || cancellation.date);
|
||||
return {
|
||||
id: `training-cancellation-${cancellation.id}`,
|
||||
cancellationId: cancellation.id,
|
||||
type: 'trainingCancellation',
|
||||
date,
|
||||
endDate,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: cancellation.reason || 'Training fällt aus',
|
||||
subtitle: 'Trainingsausfall'
|
||||
};
|
||||
});
|
||||
},
|
||||
async saveTrainingCancellation() {
|
||||
if (!this.currentClub || !this.cancellationForm.startDate) return;
|
||||
this.cancellationSaving = true;
|
||||
try {
|
||||
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
|
||||
startDate: this.cancellationForm.startDate,
|
||||
endDate: this.cancellationForm.endDate || this.cancellationForm.startDate,
|
||||
reason: this.cancellationForm.reason
|
||||
});
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
this.cancellationForm = { startDate: '', endDate: '', reason: '' };
|
||||
await this.loadCalendarEvents();
|
||||
} finally {
|
||||
this.cancellationSaving = false;
|
||||
}
|
||||
},
|
||||
getDateKeysForRange(startDate, endDate) {
|
||||
const keys = [];
|
||||
const cursor = this.startOfDay(startDate);
|
||||
const rangeEnd = this.startOfDay(endDate);
|
||||
while (cursor <= rangeEnd) {
|
||||
keys.push(this.toDateKey(cursor));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
async deleteTrainingCancellation(cancellation) {
|
||||
if (!this.currentClub || !cancellation?.cancellationId) return;
|
||||
const response = await apiClient.delete(`/training-cancellations/${this.currentClub}/${cancellation.cancellationId}`);
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
await this.loadCalendarEvents();
|
||||
},
|
||||
async loadTournamentEvents() {
|
||||
const response = await apiClient.get(`/tournament/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Turniere');
|
||||
return (response.data || []).map(tournament => {
|
||||
const date = this.parseDate(tournament.date);
|
||||
return {
|
||||
id: `tournament-${tournament.id}`,
|
||||
type: 'tournament',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: tournament.name || tournament.tournamentName || 'Turnier',
|
||||
subtitle: tournament.allowsExternal ? 'Offenes Turnier' : 'Vereinsturnier',
|
||||
route: '/tournaments'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadMatchEvents() {
|
||||
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
|
||||
this.ensureSuccess(response, 'Punktspiele');
|
||||
return (response.data || []).map(match => {
|
||||
const date = this.parseDate(match.date);
|
||||
const home = match.homeTeam?.name || 'Heim';
|
||||
const guest = match.guestTeam?.name || 'Gast';
|
||||
return {
|
||||
id: `match-${match.id}`,
|
||||
type: 'match',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date, match.time),
|
||||
time: this.formatTime(match.time),
|
||||
title: `${home} - ${guest}`,
|
||||
subtitle: match.leagueDetails?.name || match.league?.name || 'Punktspiel',
|
||||
route: '/schedule'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadHolidayEvents() {
|
||||
const response = await apiClient.get(`/calendar/club/${this.currentClub}/holidays`, {
|
||||
params: { year: this.displayedYear }
|
||||
});
|
||||
this.ensureSuccess(response, 'Ferien/Feiertage');
|
||||
const holidays = response.data?.holidays || [];
|
||||
const schoolHolidays = response.data?.schoolHolidays || [];
|
||||
return [
|
||||
...holidays.map(entry => this.mapCalendarDayEvent(entry, 'holiday', 'Feiertag')),
|
||||
...schoolHolidays.map(entry => this.mapCalendarDayEvent(entry, 'schoolHoliday', 'Schulferien'))
|
||||
].filter(Boolean);
|
||||
},
|
||||
mapCalendarDayEvent(entry, type, fallbackTitle) {
|
||||
const date = this.parseDate(entry.startDate);
|
||||
const endDate = this.parseDate(entry.endDate || entry.startDate);
|
||||
if (!date || !endDate) return null;
|
||||
return {
|
||||
id: `${type}-${entry.id || entry.startDate}`,
|
||||
type,
|
||||
date,
|
||||
endDate,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: entry.name || fallbackTitle,
|
||||
subtitle: fallbackTitle
|
||||
};
|
||||
},
|
||||
ensureSuccess(response, source) {
|
||||
if (!response || response.status >= 400) {
|
||||
const error = new Error(`HTTP ${response?.status || 0}`);
|
||||
error.source = source;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
toggleType(type) {
|
||||
this.activeTypes = {
|
||||
...this.activeTypes,
|
||||
[type]: !this.activeTypes[type]
|
||||
};
|
||||
},
|
||||
parseDate(value) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
const text = String(value).slice(0, 10);
|
||||
const [year, month, day] = text.split('-').map(Number);
|
||||
if (!year || !month || !day) return new Date(value);
|
||||
return new Date(year, month - 1, day);
|
||||
},
|
||||
combineDateTime(date, time) {
|
||||
if (!date) return Number.POSITIVE_INFINITY;
|
||||
const result = new Date(date);
|
||||
const [hours, minutes] = String(time || '').split(':').map(Number);
|
||||
result.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||
return result.getTime();
|
||||
},
|
||||
isEventOnDate(event, date) {
|
||||
const eventStart = this.startOfDay(event.date);
|
||||
const eventEnd = this.startOfDay(event.endDate || event.date);
|
||||
const candidate = this.startOfDay(date);
|
||||
return candidate >= eventStart && candidate <= eventEnd;
|
||||
},
|
||||
isEventInRange(event, rangeStart, rangeEnd) {
|
||||
const eventStart = this.startOfDay(event.date);
|
||||
const eventEnd = this.startOfDay(event.endDate || event.date);
|
||||
return eventStart <= this.startOfDay(rangeEnd) && eventEnd >= this.startOfDay(rangeStart);
|
||||
},
|
||||
startOfDay(date) {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
},
|
||||
toDateKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return '';
|
||||
return String(value).slice(0, 5);
|
||||
},
|
||||
formatTimeRange(start, end) {
|
||||
const startTime = this.formatTime(start);
|
||||
const endTime = this.formatTime(end);
|
||||
if (startTime && endTime) return `${startTime}-${endTime}`;
|
||||
return startTime || endTime || '';
|
||||
},
|
||||
formatShortDate(date) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
},
|
||||
formatEventDate(event) {
|
||||
if (!event.endDate || this.toDateKey(event.date) === this.toDateKey(event.endDate)) {
|
||||
return this.formatShortDate(event.date);
|
||||
}
|
||||
return `${this.formatShortDate(event.date)} - ${this.formatShortDate(event.endDate)}`;
|
||||
},
|
||||
goToPreviousMonth() {
|
||||
this.cursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth() - 1, 1);
|
||||
},
|
||||
goToNextMonth() {
|
||||
this.cursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth() + 1, 1);
|
||||
},
|
||||
goToToday() {
|
||||
const today = new Date();
|
||||
this.cursor = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
},
|
||||
openEvent(event) {
|
||||
if (event.route) {
|
||||
this.$router.push(event.route);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-header,
|
||||
.calendar-toolbar,
|
||||
.training-cancellation-panel,
|
||||
.calendar-agenda {
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.calendar-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2f7a5f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
margin: 0;
|
||||
color: #14251f;
|
||||
}
|
||||
|
||||
.calendar-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.calendar-actions,
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calendar-nav-button,
|
||||
.calendar-today-button {
|
||||
border: 1px solid #cfdad4;
|
||||
border-radius: 8px;
|
||||
background: #f8fbf9;
|
||||
color: #173d31;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav-button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.calendar-today-button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #d6e2dc;
|
||||
border-radius: 999px;
|
||||
background: #f8fbf9;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: #40524b;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
.legend-item.inactive {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.legend-item::before {
|
||||
content: '';
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-training::before { background: #2f7a5f; }
|
||||
.legend-tournament::before { background: #b7791f; }
|
||||
.legend-match::before { background: #2563eb; }
|
||||
.legend-holiday::before { background: #dc2626; }
|
||||
.legend-schoolHoliday::before { background: #7c3aed; }
|
||||
.legend-trainingCancellation::before { background: #64748b; }
|
||||
|
||||
.training-cancellation-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(18rem, 2fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.training-cancellation-panel h3 {
|
||||
margin: 0;
|
||||
color: #14251f;
|
||||
}
|
||||
|
||||
.training-cancellation-panel p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #607169;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 10rem minmax(12rem, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form label span {
|
||||
color: #607169;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.training-cancellation-form input,
|
||||
.training-cancellation-form button {
|
||||
border: 1px solid #cfdad4;
|
||||
border-radius: 8px;
|
||||
min-height: 2.4rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form button {
|
||||
background: #2f7a5f;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.training-cancellation-list {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.training-cancellation-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
.training-cancellation-item small {
|
||||
color: #991b1b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-state {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: #eef6f2;
|
||||
color: #255545;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-state-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-state-warning {
|
||||
background: #fff7e6;
|
||||
color: #8a4b11;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
background: #dfe7e2;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.calendar-weekday,
|
||||
.calendar-day {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding: 0.7rem;
|
||||
color: #607169;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 9.5rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.calendar-day.outside-month {
|
||||
background: #f6f8f7;
|
||||
color: #9aa8a1;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
box-shadow: inset 0 0 0 2px #2f7a5f;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
margin-bottom: 0.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.calendar-event,
|
||||
.agenda-item {
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
padding: 0.4rem 0.45rem;
|
||||
border-radius: 7px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.event-training { background: #e8f4ef; border-left: 4px solid #2f7a5f; }
|
||||
.event-tournament { background: #fff7e6; border-left: 4px solid #b7791f; }
|
||||
.event-match { background: #eaf1ff; border-left: 4px solid #2563eb; }
|
||||
.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-time {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
color: #4b5d55;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-subtitle {
|
||||
overflow: hidden;
|
||||
color: #5b6b64;
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-agenda {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calendar-agenda h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.agenda-empty {
|
||||
margin: 0;
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr auto;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0;
|
||||
border-top: 1px solid #edf1ee;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agenda-date {
|
||||
color: #607169;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.agenda-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agenda-main strong,
|
||||
.agenda-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenda-main small {
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.agenda-time {
|
||||
color: #40524b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.calendar-header,
|
||||
.calendar-toolbar,
|
||||
.training-cancellation-panel {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.training-cancellation-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-weekday,
|
||||
.calendar-day.outside-month {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: auto;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.calendar-day:not(:has(.calendar-event)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,28 @@
|
||||
<input v-model="associationMemberNumber" class="text-input" :placeholder="$t('clubSettings.associationMemberNumberPlaceholder')" />
|
||||
</section>
|
||||
|
||||
<section v-if="currentClub && !loading" class="card">
|
||||
<h2>Kalenderregion</h2>
|
||||
<p class="hint">Die Region wird für Schulferien und gesetzliche Feiertage im Kalender genutzt.</p>
|
||||
<div class="field-grid">
|
||||
<div class="field-group">
|
||||
<label>Land</label>
|
||||
<select v-model="countryCode" class="text-input">
|
||||
<option value="DE">Deutschland</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Bundesland</label>
|
||||
<select v-model="stateCode" class="text-input">
|
||||
<option value="">Nicht gesetzt</option>
|
||||
<option v-for="state in germanStates" :key="state.code" :value="state.code">
|
||||
{{ state.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="currentClub && !loading" class="card">
|
||||
<h2>{{ $t('clubSettings.myTischtennisRankings') }}</h2>
|
||||
<p class="hint">{{ $t('clubSettings.myTischtennisRankingsHint') }}</p>
|
||||
@@ -133,6 +155,25 @@ const defaultMemberDataQualityRequirements = () => ({
|
||||
requireEmail: true,
|
||||
});
|
||||
|
||||
const GERMAN_STATES = [
|
||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'DE-BY', name: 'Bayern' },
|
||||
{ code: 'DE-BE', name: 'Berlin' },
|
||||
{ code: 'DE-BB', name: 'Brandenburg' },
|
||||
{ code: 'DE-HB', name: 'Bremen' },
|
||||
{ code: 'DE-HH', name: 'Hamburg' },
|
||||
{ code: 'DE-HE', name: 'Hessen' },
|
||||
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'DE-NI', name: 'Niedersachsen' },
|
||||
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'DE-SL', name: 'Saarland' },
|
||||
{ code: 'DE-SN', name: 'Sachsen' },
|
||||
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'DE-TH', name: 'Thüringen' },
|
||||
];
|
||||
|
||||
export default {
|
||||
name: 'ClubSettings',
|
||||
components: {
|
||||
@@ -144,6 +185,8 @@ export default {
|
||||
activeTab: 'settings',
|
||||
greeting: '',
|
||||
associationMemberNumber: '',
|
||||
countryCode: 'DE',
|
||||
stateCode: '',
|
||||
myTischtennisFedNickname: '',
|
||||
autoFetchRankings: false,
|
||||
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
|
||||
@@ -154,6 +197,9 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
germanStates() {
|
||||
return GERMAN_STATES;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentClub: {
|
||||
@@ -168,6 +214,8 @@ export default {
|
||||
if (!this.currentClub) {
|
||||
this.greeting = '';
|
||||
this.associationMemberNumber = '';
|
||||
this.countryCode = 'DE';
|
||||
this.stateCode = '';
|
||||
this.myTischtennisFedNickname = '';
|
||||
this.autoFetchRankings = false;
|
||||
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
|
||||
@@ -181,6 +229,8 @@ export default {
|
||||
const club = response.data;
|
||||
this.greeting = club?.greetingText ?? '';
|
||||
this.associationMemberNumber = club?.associationMemberNumber ?? '';
|
||||
this.countryCode = club?.countryCode ?? 'DE';
|
||||
this.stateCode = club?.stateCode ?? '';
|
||||
this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? '';
|
||||
this.autoFetchRankings = !!club?.autoFetchRankings;
|
||||
this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(club?.memberDataQualityRequirements);
|
||||
@@ -188,6 +238,8 @@ export default {
|
||||
this.loadError = this.$t('clubSettings.loadFailed');
|
||||
this.greeting = '';
|
||||
this.associationMemberNumber = '';
|
||||
this.countryCode = 'DE';
|
||||
this.stateCode = '';
|
||||
this.myTischtennisFedNickname = '';
|
||||
this.autoFetchRankings = false;
|
||||
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
|
||||
@@ -224,6 +276,8 @@ export default {
|
||||
await apiClient.put(`/clubs/${this.currentClub}/settings`, {
|
||||
greetingText: this.greeting,
|
||||
associationMemberNumber: this.associationMemberNumber,
|
||||
countryCode: this.countryCode,
|
||||
stateCode: this.stateCode || null,
|
||||
myTischtennisFedNickname: this.myTischtennisFedNickname || null,
|
||||
autoFetchRankings: this.autoFetchRankings,
|
||||
memberDataQualityRequirements: this.normalizeMemberDataQualityRequirements(this.memberDataQualityRequirements),
|
||||
@@ -264,6 +318,7 @@ export default {
|
||||
.text-input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 8px; font-size: 14px; }
|
||||
.rankings-row { margin-bottom: 12px; }
|
||||
.rankings-fields { margin-top: 12px; }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.quality-options { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.field-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
@@ -305,5 +360,8 @@ export default {
|
||||
color: #28a745;
|
||||
border-bottom-color: #28a745;
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.field-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user