From 004801b1a630f2767f9a4492d5f8bb7af8bf191a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 13 May 2026 10:21:30 +0200 Subject: [PATCH] feat(Calendar): integrate CalendarEvent model and enhance calendar functionality - 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. --- .../controllers/calendarEventController.js | 42 + .../create_calendar_events_table.sql | 15 + backend/models/CalendarEvent.js | 25 + backend/models/index.js | 4 + backend/routes/calendarEventRoutes.js | 16 + backend/server.js | 4 + backend/services/calendarEventService.js | 59 + frontend/src/views/CalendarView.vue | 413 +++++- mobile-app/DEVELOPMENT.md | 42 + mobile-app/TODO.md | 37 +- mobile-app/composeApp/build.gradle.kts | 5 + .../de/tt_tagebuch/app/AppDependencies.kt | 22 +- .../app/calendar/CalendarAggregator.kt | 393 ++++++ .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 1102 +++++++++++------ .../app/ui/BillingOrdersScreens.kt | 14 +- .../de/tt_tagebuch/app/ui/CalendarScreen.kt | 498 ++++++++ .../app/ui/ClubStammdatenScreens.kt | 63 +- .../de/tt_tagebuch/app/ui/ScheduleScreen.kt | 279 +++-- .../tt_tagebuch/app/ui/TournamentsScreen.kt | 36 +- .../shared/api/CalendarHolidayApi.kt | 16 + .../shared/api/TrainingCancellationApi.kt | 32 + .../shared/api/http/AuthedHttpClient.kt | 1 + .../shared/api/models/CalendarDtos.kt | 34 + .../de/tt_tagebuch/shared/api/models/Club.kt | 21 + .../api/models/ClubPermissionHelpers.kt | 6 + .../api/models/FlexibleBooleanSerializers.kt | 55 + .../tt_tagebuch/shared/api/models/Schedule.kt | 4 + .../shared/api/models/TournamentDtos.kt | 4 + .../serialization/LenientIntListSerializer.kt | 47 + .../kotlin/de/tt_tagebuch/shared/di/Koin.kt | 18 +- .../tt_tagebuch/shared/state/ClubManager.kt | 34 +- .../tt_tagebuch/shared/state/DiaryManager.kt | 3 + .../state/OfficialTournamentsReadManager.kt | 3 + 33 files changed, 2715 insertions(+), 632 deletions(-) create mode 100644 backend/controllers/calendarEventController.js create mode 100644 backend/migrations/create_calendar_events_table.sql create mode 100644 backend/models/CalendarEvent.js create mode 100644 backend/routes/calendarEventRoutes.js create mode 100644 backend/services/calendarEventService.js create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/calendar/CalendarAggregator.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/CalendarScreen.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/CalendarHolidayApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingCancellationApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/CalendarDtos.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/FlexibleBooleanSerializers.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/serialization/LenientIntListSerializer.kt diff --git a/backend/controllers/calendarEventController.js b/backend/controllers/calendarEventController.js new file mode 100644 index 00000000..4133c97c --- /dev/null +++ b/backend/controllers/calendarEventController.js @@ -0,0 +1,42 @@ +import calendarEventService from '../services/calendarEventService.js'; +import { getSafeErrorMessage } from '../utils/errorUtils.js'; + +export const listClubCalendarEvents = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const { year } = req.query; + const events = await calendarEventService.listClubEvents(userToken, clubId, year); + res.status(200).json(events); + } catch (error) { + console.error('[listClubCalendarEvents] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Kalender-Events'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + +export const createClubCalendarEvent = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const event = await calendarEventService.createClubEvent(userToken, clubId, req.body); + res.status(201).json(event); + } catch (error) { + console.error('[createClubCalendarEvent] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + +export const deleteClubCalendarEvent = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId, eventId } = req.params; + const result = await calendarEventService.deleteClubEvent(userToken, clubId, eventId); + res.status(200).json(result); + } catch (error) { + console.error('[deleteClubCalendarEvent] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Löschen des Kalender-Events'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; diff --git a/backend/migrations/create_calendar_events_table.sql b/backend/migrations/create_calendar_events_table.sql new file mode 100644 index 00000000..5d3dbc38 --- /dev/null +++ b/backend/migrations/create_calendar_events_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS calendar_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + category VARCHAR(64) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_calendar_events_club_start (club_id, start_date), + CONSTRAINT fk_calendar_events_club + FOREIGN KEY (club_id) REFERENCES clubs(id) + ON DELETE CASCADE +); diff --git a/backend/models/CalendarEvent.js b/backend/models/CalendarEvent.js new file mode 100644 index 00000000..44fcc7fb --- /dev/null +++ b/backend/models/CalendarEvent.js @@ -0,0 +1,25 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Club from './Club.js'; + +const CalendarEvent = sequelize.define('CalendarEvent', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + clubId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: Club, key: 'id' }, + onDelete: 'CASCADE', + }, + title: { type: DataTypes.STRING(255), allowNull: false }, + startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' }, + endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' }, + category: { type: DataTypes.STRING(64), allowNull: true }, + notes: { type: DataTypes.TEXT, allowNull: true }, +}, { + tableName: 'calendar_events', + underscored: true, + timestamps: true, + indexes: [{ fields: ['club_id', 'start_date'] }], +}); + +export default CalendarEvent; diff --git a/backend/models/index.js b/backend/models/index.js index 44c28dcf..82113b9f 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -59,6 +59,7 @@ import MemberTrainingGroup from './MemberTrainingGroup.js'; import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js'; import TrainingTime from './TrainingTime.js'; import TrainingCancellation from './TrainingCancellation.js'; +import CalendarEvent from './CalendarEvent.js'; import BillingTemplate from './BillingTemplate.js'; import BillingTemplateField from './BillingTemplateField.js'; import BillingRun from './BillingRun.js'; @@ -410,6 +411,8 @@ TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'traini TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' }); Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' }); TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Club.hasMany(CalendarEvent, { foreignKey: 'clubId', as: 'calendarEvents' }); +CalendarEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); // Billing Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' }); @@ -488,6 +491,7 @@ export { ClubDisabledPresetGroup, TrainingTime, TrainingCancellation, + CalendarEvent, BillingTemplate, BillingTemplateField, BillingRun, diff --git a/backend/routes/calendarEventRoutes.js b/backend/routes/calendarEventRoutes.js new file mode 100644 index 00000000..2dd2f25c --- /dev/null +++ b/backend/routes/calendarEventRoutes.js @@ -0,0 +1,16 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { + createClubCalendarEvent, + deleteClubCalendarEvent, + listClubCalendarEvents, +} from '../controllers/calendarEventController.js'; + +const router = express.Router(); +router.use(authenticate); + +router.get('/:clubId', listClubCalendarEvents); +router.post('/:clubId', createClubCalendarEvent); +router.delete('/:clubId/:eventId', deleteClubCalendarEvent); + +export default router; diff --git a/backend/server.js b/backend/server.js index aa1965e0..4baa145e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -15,6 +15,7 @@ import { GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest, MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, TrainingCancellation + , CalendarEvent } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -60,6 +61,7 @@ import memberOrderRoutes from './routes/memberOrderRoutes.js'; import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js'; import billingRoutes from './routes/billingRoutes.js'; import calendarRoutes from './routes/calendarRoutes.js'; +import calendarEventRoutes from './routes/calendarEventRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; import HttpError from './exceptions/HttpError.js'; @@ -310,6 +312,7 @@ app.use('/api/member-orders', memberOrderRoutes); app.use('/api/member-group-photos', memberGroupPhotoRoutes); app.use('/api/billing', billingRoutes); app.use('/api/calendar', calendarRoutes); +app.use('/api/calendar-events', calendarEventRoutes); // Middleware für dynamischen kanonischen Tag (vor express.static) const setCanonicalTag = (req, res, next) => { @@ -565,6 +568,7 @@ app.use((err, req, res, next) => { await safeSync(BillingUserSetting); await safeSync(ClubTeam); await safeSync(TrainingCancellation); + await safeSync(CalendarEvent); await safeSync(TeamDocument); // Foreign Keys wieder aktivieren diff --git a/backend/services/calendarEventService.js b/backend/services/calendarEventService.js new file mode 100644 index 00000000..d332ab47 --- /dev/null +++ b/backend/services/calendarEventService.js @@ -0,0 +1,59 @@ +import { Op } from 'sequelize'; +import CalendarEvent from '../models/CalendarEvent.js'; +import { checkAccess } from '../utils/userUtils.js'; +import HttpError from '../exceptions/HttpError.js'; + +class CalendarEventService { + async listClubEvents(userToken, clubId, year) { + await checkAccess(userToken, clubId); + const normalizedYear = this.normalizeYear(year); + return await CalendarEvent.findAll({ + where: { + clubId, + startDate: { [Op.lte]: `${normalizedYear}-12-31` }, + endDate: { [Op.gte]: `${normalizedYear}-01-01` }, + }, + order: [['startDate', 'ASC'], ['title', 'ASC']], + }); + } + + async createClubEvent(userToken, clubId, payload) { + await checkAccess(userToken, clubId); + const title = String(payload?.title || '').trim(); + if (!title) throw new HttpError('Titel fehlt', 400); + const startDate = this.normalizeDate(payload?.startDate); + const endDate = this.normalizeDate(payload?.endDate || payload?.startDate); + if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400); + if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400); + + return await CalendarEvent.create({ + clubId, + title, + startDate, + endDate, + category: payload?.category ? String(payload.category).trim().slice(0, 64) : null, + notes: payload?.notes ? String(payload.notes).trim() : null, + }); + } + + async deleteClubEvent(userToken, clubId, eventId) { + await checkAccess(userToken, clubId); + const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } }); + if (!event) throw new HttpError('Event nicht gefunden', 404); + await event.destroy(); + return { success: true }; + } + + normalizeYear(year) { + const parsed = Number.parseInt(year, 10); + if (Number.isInteger(parsed) && parsed >= 2020 && parsed <= 2100) return parsed; + return new Date().getFullYear(); + } + + normalizeDate(date) { + const text = String(date || '').slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null; + } +} + +export default new CalendarEventService(); diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index de5168b2..7f861609 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -29,39 +29,78 @@ -
-
-

Training fällt aus

-

Hier eingetragene Tage blenden die regelmäßigen Trainingszeiten aus.

+
+ + Optionen + Ausfälle & eigene Termine + +
+
+
+

Training fällt aus

+

Blendet regelmäßige Trainingszeiten aus.

+
+
+ + + + +
+
+ +
+
+ +
+
+

Eigene Termine

+

Kreistage, Sitzungen, interne Meetings, ...

+
+
+ + + + + +
+
+ +
+
-
- - - - -
-
- -
-
+
{{ sourceWarnings.join(' · ') }} @@ -116,17 +155,34 @@ {{ event.time }}
+ +