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