From 54ce09e9a9a3817d3ad92318eb1b1a7630f3778c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 15 Nov 2025 20:51:08 +0100 Subject: [PATCH] Add training times management and enhance diary view with group selection dialog This commit introduces the `TrainingTime` model and related functionality, allowing for the management of training times associated with training groups. The backend is updated to include new routes for training times, while the frontend is enhanced with a new dialog in the `DiaryView` for selecting training groups and suggesting available training times. This improves user experience by streamlining the process of scheduling training sessions and managing associated data. --- backend/controllers/trainingTimeController.js | 80 +++ .../create_training_times_table.sql | 19 + backend/models/TrainingTime.js | 47 ++ backend/models/index.js | 6 + backend/routes/trainingTimeRoutes.js | 19 + backend/scripts/cleanupAllIndexes.js | 17 +- backend/server.js | 2 + backend/services/trainingTimeService.js | 142 +++++ frontend/src/components/TrainingTimesTab.vue | 483 ++++++++++++++++++ frontend/src/views/ClubSettings.vue | 14 + frontend/src/views/DiaryView.vue | 316 +++++++++++- 11 files changed, 1117 insertions(+), 28 deletions(-) create mode 100644 backend/controllers/trainingTimeController.js create mode 100644 backend/migrations/create_training_times_table.sql create mode 100644 backend/models/TrainingTime.js create mode 100644 backend/routes/trainingTimeRoutes.js create mode 100644 backend/services/trainingTimeService.js create mode 100644 frontend/src/components/TrainingTimesTab.vue diff --git a/backend/controllers/trainingTimeController.js b/backend/controllers/trainingTimeController.js new file mode 100644 index 0000000..92d379c --- /dev/null +++ b/backend/controllers/trainingTimeController.js @@ -0,0 +1,80 @@ +import trainingTimeService from '../services/trainingTimeService.js'; +import { getSafeErrorMessage } from '../utils/errorUtils.js'; + +export const getTrainingTimes = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const groups = await trainingTimeService.getTrainingTimes(userToken, clubId); + res.status(200).json(groups); + } catch (error) { + console.error('[getTrainingTimes] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingszeiten'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + +export const createTrainingTime = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const { trainingGroupId, weekday, startTime, endTime } = req.body; + + if (!trainingGroupId || weekday === undefined || !startTime || !endTime) { + return res.status(400).json({ error: 'Alle Felder müssen ausgefüllt sein' }); + } + + const trainingTime = await trainingTimeService.createTrainingTime( + userToken, + clubId, + trainingGroupId, + weekday, + startTime, + endTime + ); + + res.status(201).json(trainingTime); + } catch (error) { + console.error('[createTrainingTime] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingszeit'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + +export const updateTrainingTime = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId, timeId } = req.params; + const { weekday, startTime, endTime } = req.body; + + const trainingTime = await trainingTimeService.updateTrainingTime( + userToken, + clubId, + timeId, + weekday, + startTime, + endTime + ); + + res.status(200).json(trainingTime); + } catch (error) { + console.error('[updateTrainingTime] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingszeit'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + +export const deleteTrainingTime = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId, timeId } = req.params; + + const result = await trainingTimeService.deleteTrainingTime(userToken, clubId, timeId); + res.status(200).json(result); + } catch (error) { + console.error('[deleteTrainingTime] - Error:', error); + const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingszeit'); + res.status(error.statusCode || 500).json({ error: msg }); + } +}; + diff --git a/backend/migrations/create_training_times_table.sql b/backend/migrations/create_training_times_table.sql new file mode 100644 index 0000000..836f911 --- /dev/null +++ b/backend/migrations/create_training_times_table.sql @@ -0,0 +1,19 @@ +-- Migration: Create training_times table +-- Date: 2025-01-16 +-- For MariaDB/MySQL +-- Stores training times for training groups + +CREATE TABLE IF NOT EXISTS `training_times` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `training_group_id` INT(11) NOT NULL, + `weekday` TINYINT(1) NOT NULL COMMENT '0 = Sunday, 1 = Monday, ..., 6 = Saturday', + `start_time` TIME NOT NULL, + `end_time` TIME NOT NULL, + `sort_order` INT(11) NOT NULL DEFAULT 0 COMMENT 'Order for displaying multiple times on the same weekday', + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `training_group_id` (`training_group_id`), + CONSTRAINT `training_times_ibfk_1` FOREIGN KEY (`training_group_id`) REFERENCES `training_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/backend/models/TrainingTime.js b/backend/models/TrainingTime.js new file mode 100644 index 0000000..27c67d4 --- /dev/null +++ b/backend/models/TrainingTime.js @@ -0,0 +1,47 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import TrainingGroup from './TrainingGroup.js'; + +const TrainingTime = sequelize.define('TrainingTime', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + trainingGroupId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: TrainingGroup, + key: 'id', + }, + onDelete: 'CASCADE', + }, + weekday: { + type: DataTypes.INTEGER, + allowNull: false, + comment: '0 = Sunday, 1 = Monday, ..., 6 = Saturday' + }, + startTime: { + type: DataTypes.TIME, + allowNull: false, + }, + endTime: { + type: DataTypes.TIME, + allowNull: false, + }, + sortOrder: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + comment: 'Order for displaying multiple times on the same weekday' + } +}, { + tableName: 'training_times', + underscored: true, + timestamps: true, +}); + +export default TrainingTime; + diff --git a/backend/models/index.js b/backend/models/index.js index a210585..ad9253f 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -47,6 +47,7 @@ import MemberImage from './MemberImage.js'; import TrainingGroup from './TrainingGroup.js'; import MemberTrainingGroup from './MemberTrainingGroup.js'; import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js'; +import TrainingTime from './TrainingTime.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -316,6 +317,10 @@ TrainingGroup.belongsToMany(Member, { Club.hasMany(ClubDisabledPresetGroup, { foreignKey: 'clubId', as: 'disabledPresetGroups' }); ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +// Training Times +TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' }); +TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' }); + export { User, Log, @@ -365,4 +370,5 @@ export { TrainingGroup, MemberTrainingGroup, ClubDisabledPresetGroup, + TrainingTime, }; diff --git a/backend/routes/trainingTimeRoutes.js b/backend/routes/trainingTimeRoutes.js new file mode 100644 index 0000000..82e58c3 --- /dev/null +++ b/backend/routes/trainingTimeRoutes.js @@ -0,0 +1,19 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { + getTrainingTimes, + createTrainingTime, + updateTrainingTime, + deleteTrainingTime +} from '../controllers/trainingTimeController.js'; + +const router = express.Router(); +router.use(authenticate); + +router.get('/:clubId', getTrainingTimes); +router.post('/:clubId', createTrainingTime); +router.put('/:clubId/:timeId', updateTrainingTime); +router.delete('/:clubId/:timeId', deleteTrainingTime); + +export default router; + diff --git a/backend/scripts/cleanupAllIndexes.js b/backend/scripts/cleanupAllIndexes.js index 36c7569..e3b39e9 100644 --- a/backend/scripts/cleanupAllIndexes.js +++ b/backend/scripts/cleanupAllIndexes.js @@ -1,15 +1,22 @@ import mysql from 'mysql2/promise'; import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { development } from '../config.js'; -dotenv.config(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load .env from backend directory +dotenv.config({ path: join(__dirname, '..', '.env') }); const report = []; const dbConfig = { - host: process.env.DB_HOST || 'localhost', - user: process.env.DB_USER || 'root', - password: process.env.DB_PASSWORD || '', - database: process.env.DB_NAME || 'trainingdiary', + host: process.env.DB_HOST || development.host || 'localhost', + user: process.env.DB_USER || development.username || 'root', + password: process.env.DB_PASSWORD || development.password || '', + database: process.env.DB_NAME || development.database || 'trainingdiary', }; async function getTables(connection) { diff --git a/backend/server.js b/backend/server.js index 0917a82..9b5102e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -47,6 +47,7 @@ import permissionRoutes from './routes/permissionRoutes.js'; import apiLogRoutes from './routes/apiLogRoutes.js'; import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js'; import trainingGroupRoutes from './routes/trainingGroupRoutes.js'; +import trainingTimeRoutes from './routes/trainingTimeRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; @@ -134,6 +135,7 @@ app.use('/api/permissions', permissionRoutes); app.use('/api/logs', apiLogRoutes); app.use('/api/member-transfer-config', memberTransferConfigRoutes); app.use('/api/training-groups', trainingGroupRoutes); +app.use('/api/training-times', trainingTimeRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); diff --git a/backend/services/trainingTimeService.js b/backend/services/trainingTimeService.js new file mode 100644 index 0000000..11159a8 --- /dev/null +++ b/backend/services/trainingTimeService.js @@ -0,0 +1,142 @@ +import { checkAccess } from '../utils/userUtils.js'; +import TrainingTime from '../models/TrainingTime.js'; +import TrainingGroup from '../models/TrainingGroup.js'; +import HttpError from '../exceptions/HttpError.js'; + +class TrainingTimeService { + async getTrainingTimes(userToken, clubId) { + await checkAccess(userToken, clubId); + + const groups = await TrainingGroup.findAll({ + where: { clubId }, + include: [{ + model: TrainingTime, + as: 'trainingTimes', + required: false, + separate: true, + order: [['weekday', 'ASC'], ['sortOrder', 'ASC'], ['startTime', 'ASC']] + }], + order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']] + }); + + return groups.map(group => { + const groupData = group.toJSON ? group.toJSON() : group; + return { + ...groupData, + trainingTimes: Array.isArray(groupData.trainingTimes) + ? groupData.trainingTimes.sort((a, b) => { + if (a.weekday !== b.weekday) return a.weekday - b.weekday; + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; + return a.startTime.localeCompare(b.startTime); + }) + : [] + }; + }); + } + + async createTrainingTime(userToken, clubId, trainingGroupId, weekday, startTime, endTime) { + await checkAccess(userToken, clubId); + + // Validate that the training group belongs to the club + const group = await TrainingGroup.findOne({ + where: { id: trainingGroupId, clubId } + }); + + if (!group) { + throw new HttpError('Trainingsgruppe nicht gefunden', 404); + } + + // Validate weekday (0-6) + if (weekday < 0 || weekday > 6) { + throw new HttpError('Ungültiger Wochentag (0-6)', 400); + } + + // Validate times + if (!startTime || !endTime) { + throw new HttpError('Start- und Endzeit müssen angegeben werden', 400); + } + + if (startTime >= endTime) { + throw new HttpError('Startzeit muss vor Endzeit liegen', 400); + } + + // Get max sortOrder for this weekday and group + const maxSortOrder = await TrainingTime.max('sortOrder', { + where: { + trainingGroupId, + weekday + } + }) || 0; + + const trainingTime = await TrainingTime.create({ + trainingGroupId, + weekday, + startTime, + endTime, + sortOrder: maxSortOrder + 1 + }); + + return trainingTime; + } + + async updateTrainingTime(userToken, clubId, timeId, weekday, startTime, endTime) { + await checkAccess(userToken, clubId); + + const trainingTime = await TrainingTime.findByPk(timeId, { + include: [{ + model: TrainingGroup, + as: 'trainingGroup' + }] + }); + + if (!trainingTime || !trainingTime.trainingGroup) { + throw new HttpError('Trainingszeit nicht gefunden', 404); + } + + if (trainingTime.trainingGroup.clubId !== parseInt(clubId)) { + throw new HttpError('Keine Berechtigung', 403); + } + + // Validate weekday (0-6) + if (weekday !== undefined && (weekday < 0 || weekday > 6)) { + throw new HttpError('Ungültiger Wochentag (0-6)', 400); + } + + // Validate times + if (startTime && endTime && startTime >= endTime) { + throw new HttpError('Startzeit muss vor Endzeit liegen', 400); + } + + if (weekday !== undefined) trainingTime.weekday = weekday; + if (startTime !== undefined) trainingTime.startTime = startTime; + if (endTime !== undefined) trainingTime.endTime = endTime; + + await trainingTime.save(); + return trainingTime; + } + + async deleteTrainingTime(userToken, clubId, timeId) { + await checkAccess(userToken, clubId); + + const trainingTime = await TrainingTime.findByPk(timeId, { + include: [{ + model: TrainingGroup, + as: 'trainingGroup' + }] + }); + + if (!trainingTime || !trainingTime.trainingGroup) { + throw new HttpError('Trainingszeit nicht gefunden', 404); + } + + if (trainingTime.trainingGroup.clubId !== parseInt(clubId)) { + throw new HttpError('Keine Berechtigung', 403); + } + + await trainingTime.destroy(); + return { success: true }; + } +} + +export default new TrainingTimeService(); + diff --git a/frontend/src/components/TrainingTimesTab.vue b/frontend/src/components/TrainingTimesTab.vue new file mode 100644 index 0000000..0baa722 --- /dev/null +++ b/frontend/src/components/TrainingTimesTab.vue @@ -0,0 +1,483 @@ + + + + + + diff --git a/frontend/src/views/ClubSettings.vue b/frontend/src/views/ClubSettings.vue index 62b9946..fa535e0 100644 --- a/frontend/src/views/ClubSettings.vue +++ b/frontend/src/views/ClubSettings.vue @@ -16,6 +16,12 @@ > 👨‍👩‍👧‍👦 Trainingsgruppen + @@ -56,17 +62,25 @@ + + +
+ +
+