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 }}
+ +