feat(ClubSettings): add country and state code fields for regional calendar data
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:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View File

@@ -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') }}

View File

@@ -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 },

View File

@@ -79,6 +79,7 @@ const NOINDEX_PREFIXES = [
'/showclub',
'/members',
'/diary',
'/calendar',
'/pending-approvals',
'/schedule',
'/tournaments',

View 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>

View File

@@ -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>