From d08835e2063185b975c2c2e42b6e7f6fe5c7fc88 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 14 Nov 2025 22:36:51 +0100 Subject: [PATCH] Implement external participant management and tournament class features This commit enhances the tournament management system by introducing functionality for handling external participants and tournament classes. New methods are added to the `tournamentController` and `tournamentService` for adding, retrieving, updating, and removing external participants, as well as managing tournament classes. The backend models are updated to support these features, including new relationships and attributes. The frontend is also updated to allow users to manage external participants and classes, improving the overall user experience and interactivity in tournament management. --- backend/controllers/tournamentController.js | 147 ++- .../add_allows_external_to_tournament.sql | 22 + ..._id_to_external_tournament_participant.sql | 27 + .../add_class_id_to_tournament_group.sql | 27 + .../add_class_id_to_tournament_match.sql | 27 + .../add_class_id_to_tournament_member.sql | 22 + ...ate_in_external_tournament_participant.sql | 41 + ..._external_tournament_participant_table.sql | 22 + .../create_tournament_class_table.sql | 16 + .../models/ExternalTournamentParticipant.js | 89 ++ backend/models/Tournament.js | 5 + backend/models/TournamentClass.js | 38 + backend/models/TournamentGroup.js | 4 + backend/models/TournamentMatch.js | 4 + backend/models/TournamentMember.js | 4 + backend/models/index.js | 40 + backend/routes/tournamentRoutes.js | 28 +- backend/services/tournamentService.js | 880 ++++++++++--- frontend/src/App.vue | 8 +- frontend/src/components/PDFGenerator.js | 547 ++++++++ frontend/src/router.js | 4 +- .../src/views/ExternalTournamentsView.vue | 14 + frontend/src/views/OfficialTournaments.vue | 2 +- frontend/src/views/TournamentsView.vue | 1106 +++++++++++++++-- 24 files changed, 2798 insertions(+), 326 deletions(-) create mode 100644 backend/migrations/add_allows_external_to_tournament.sql create mode 100644 backend/migrations/add_class_id_to_external_tournament_participant.sql create mode 100644 backend/migrations/add_class_id_to_tournament_group.sql create mode 100644 backend/migrations/add_class_id_to_tournament_match.sql create mode 100644 backend/migrations/add_class_id_to_tournament_member.sql create mode 100644 backend/migrations/change_ttr_to_birth_date_in_external_tournament_participant.sql create mode 100644 backend/migrations/create_external_tournament_participant_table.sql create mode 100644 backend/migrations/create_tournament_class_table.sql create mode 100644 backend/models/ExternalTournamentParticipant.js create mode 100644 backend/models/TournamentClass.js create mode 100644 frontend/src/views/ExternalTournamentsView.vue diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index 4f1cfe1..dccfff9 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -18,9 +18,9 @@ export const getTournaments = async (req, res) => { // 2. Neues Turnier anlegen export const addTournament = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentName, date, winningSets } = req.body; + const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body; try { - const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets); + const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal); // Emit Socket-Event if (clubId && tournament && tournament.id) { emitTournamentChanged(clubId, tournament.id); @@ -82,9 +82,24 @@ export const setModus = async (req, res) => { // 6. Gruppen-Strukturen anlegen (leere Gruppen) export const createGroups = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId } = req.body; + const { clubId, tournamentId, numberOfGroups } = req.body; try { - await tournamentService.createGroups(token, clubId, tournamentId); + await tournamentService.createGroups(token, clubId, tournamentId, numberOfGroups); + // Emit Socket-Event + emitTournamentChanged(clubId, tournamentId); + res.sendStatus(204); + } catch (error) { + console.error(error); + res.status(500).json({ error: error.message }); + } +}; + +// 6b. Gruppen-Strukturen pro Klasse anlegen +export const createGroupsPerClass = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, groupsPerClass } = req.body; + try { + await tournamentService.createGroupsPerClass(token, clubId, tournamentId, groupsPerClass); // Emit Socket-Event emitTournamentChanged(clubId, tournamentId); res.sendStatus(204); @@ -360,4 +375,128 @@ export const setMatchActive = async (req, res) => { res.status(500).json({ error: err.message }); } }; + +// Externe Teilnehmer hinzufügen +export const addExternalParticipant = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, firstName, lastName, club, birthDate } = req.body; + try { + await tournamentService.addExternalParticipant(token, clubId, tournamentId, firstName, lastName, club, birthDate); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' }); + } catch (error) { + console.error('[addExternalParticipant] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +// Externe Teilnehmer abrufen +export const getExternalParticipants = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId } = req.body; + try { + const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId); + res.status(200).json(participants); + } catch (error) { + console.error('[getExternalParticipants] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +// Externe Teilnehmer löschen +export const removeExternalParticipant = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, participantId } = req.body; + try { + await tournamentService.removeExternalParticipant(token, clubId, tournamentId, participantId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Externer Teilnehmer entfernt' }); + } catch (error) { + console.error('[removeExternalParticipant] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +// Gesetzt-Status für externe Teilnehmer aktualisieren +export const updateExternalParticipantSeeded = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, participantId } = req.params; + const { seeded } = req.body; + try { + await tournamentService.updateExternalParticipantSeeded(token, clubId, tournamentId, participantId, seeded); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Gesetzt-Status aktualisiert' }); + } catch (error) { + console.error('[updateExternalParticipantSeeded] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +// Tournament Classes +export const getTournamentClasses = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId } = req.params; + try { + const classes = await tournamentService.getTournamentClasses(token, clubId, tournamentId); + res.status(200).json(classes); + } catch (error) { + console.error('[getTournamentClasses] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const addTournamentClass = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId } = req.params; + const { name } = req.body; + try { + const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json(tournamentClass); + } catch (error) { + console.error('[addTournamentClass] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const updateTournamentClass = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, classId } = req.params; + const { name, sortOrder } = req.body; + try { + const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json(tournamentClass); + } catch (error) { + console.error('[updateTournamentClass] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const deleteTournamentClass = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, classId } = req.params; + try { + await tournamentService.deleteTournamentClass(token, clubId, tournamentId, classId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Klasse gelöscht' }); + } catch (error) { + console.error('[deleteTournamentClass] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const updateParticipantClass = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, participantId } = req.params; + const { classId, isExternal } = req.body; + try { + await tournamentService.updateParticipantClass(token, clubId, tournamentId, participantId, classId, isExternal); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Klasse aktualisiert' }); + } catch (error) { + console.error('[updateParticipantClass] Error:', error); + res.status(500).json({ error: error.message }); + } +}; \ No newline at end of file diff --git a/backend/migrations/add_allows_external_to_tournament.sql b/backend/migrations/add_allows_external_to_tournament.sql new file mode 100644 index 0000000..ad90951 --- /dev/null +++ b/backend/migrations/add_allows_external_to_tournament.sql @@ -0,0 +1,22 @@ +-- Migration: Add 'allows_external' column to tournament table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament'; +SET @columnname = 'allows_external'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @columnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `winning_sets`') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + diff --git a/backend/migrations/add_class_id_to_external_tournament_participant.sql b/backend/migrations/add_class_id_to_external_tournament_participant.sql new file mode 100644 index 0000000..819c553 --- /dev/null +++ b/backend/migrations/add_class_id_to_external_tournament_participant.sql @@ -0,0 +1,27 @@ +-- Migration: Add 'class_id' column to external_tournament_participant table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'external_tournament_participant'; +SET @columnname = 'class_id'; + +-- Check if column exists +SET @column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @columnname) +); + +-- Add column if it doesn't exist +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `external_tournament_participant` ADD COLUMN `class_id` INT(11) NULL AFTER `seeded`', + 'SELECT 1 AS column_already_exists' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/backend/migrations/add_class_id_to_tournament_group.sql b/backend/migrations/add_class_id_to_tournament_group.sql new file mode 100644 index 0000000..e9eb3fb --- /dev/null +++ b/backend/migrations/add_class_id_to_tournament_group.sql @@ -0,0 +1,27 @@ +-- Migration: Add 'class_id' column to tournament_group table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament_group'; +SET @columnname = 'class_id'; + +-- Check if column exists +SET @column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @columnname) +); + +-- Add column if it doesn't exist +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `tournament_group` ADD COLUMN `class_id` INT(11) NULL AFTER `tournament_id`', + 'SELECT 1 AS column_already_exists' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/backend/migrations/add_class_id_to_tournament_match.sql b/backend/migrations/add_class_id_to_tournament_match.sql new file mode 100644 index 0000000..9e07e2c --- /dev/null +++ b/backend/migrations/add_class_id_to_tournament_match.sql @@ -0,0 +1,27 @@ +-- Migration: Add 'class_id' column to tournament_match table +-- Date: 2025-01-16 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament_match'; +SET @columnname = 'class_id'; + +-- Check if column exists +SET @column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @columnname) +); + +-- Add column if it doesn't exist +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `tournament_match` ADD COLUMN `class_id` INT(11) NULL AFTER `group_id`', + 'SELECT 1 AS column_already_exists' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/backend/migrations/add_class_id_to_tournament_member.sql b/backend/migrations/add_class_id_to_tournament_member.sql new file mode 100644 index 0000000..b14f0e2 --- /dev/null +++ b/backend/migrations/add_class_id_to_tournament_member.sql @@ -0,0 +1,22 @@ +-- Migration: Add 'class_id' column to tournament_member table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament_member'; +SET @columnname = 'class_id'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @columnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` INT(11) NULL AFTER `seeded`') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + diff --git a/backend/migrations/change_ttr_to_birth_date_in_external_tournament_participant.sql b/backend/migrations/change_ttr_to_birth_date_in_external_tournament_participant.sql new file mode 100644 index 0000000..73a6248 --- /dev/null +++ b/backend/migrations/change_ttr_to_birth_date_in_external_tournament_participant.sql @@ -0,0 +1,41 @@ +-- Migration: Change 'ttr' column to 'birth_date' in external_tournament_participant table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'external_tournament_participant'; +SET @oldcolumnname = 'ttr'; +SET @newcolumnname = 'birth_date'; + +-- Check if old column exists +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @oldcolumnname) + ) > 0, + CONCAT('ALTER TABLE `', @tablename, '` CHANGE COLUMN `', @oldcolumnname, '` `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`'), + 'SELECT 1' +)); +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +-- If old column didn't exist, check if new column exists and add it if not +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @newcolumnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + diff --git a/backend/migrations/create_external_tournament_participant_table.sql b/backend/migrations/create_external_tournament_participant_table.sql new file mode 100644 index 0000000..99614f7 --- /dev/null +++ b/backend/migrations/create_external_tournament_participant_table.sql @@ -0,0 +1,22 @@ +-- Migration: Create external_tournament_participant table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +CREATE TABLE IF NOT EXISTS `external_tournament_participant` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `tournament_id` INT(11) NOT NULL, + `group_id` INT(11) NULL, + `first_name` VARCHAR(255) NOT NULL, + `last_name` VARCHAR(255) NOT NULL, + `club` VARCHAR(255) NULL, + `birth_date` VARCHAR(255) NULL, + `seeded` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + INDEX `idx_tournament_id` (`tournament_id`), + INDEX `idx_group_id` (`group_id`), + CONSTRAINT `fk_external_participant_tournament` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_external_participant_group` FOREIGN KEY (`group_id`) REFERENCES `tournament_group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/backend/migrations/create_tournament_class_table.sql b/backend/migrations/create_tournament_class_table.sql new file mode 100644 index 0000000..84b0e9a --- /dev/null +++ b/backend/migrations/create_tournament_class_table.sql @@ -0,0 +1,16 @@ +-- Migration: Create tournament_class table +-- Date: 2025-01-15 +-- For MariaDB/MySQL + +CREATE TABLE IF NOT EXISTS `tournament_class` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `tournament_id` INT(11) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `sort_order` INT(11) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `tournament_id` (`tournament_id`), + CONSTRAINT `tournament_class_ibfk_1` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/backend/models/ExternalTournamentParticipant.js b/backend/models/ExternalTournamentParticipant.js new file mode 100644 index 0000000..cc82f5f --- /dev/null +++ b/backend/models/ExternalTournamentParticipant.js @@ -0,0 +1,89 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import { encryptData, decryptData } from '../utils/encrypt.js'; + +const ExternalTournamentParticipant = sequelize.define('ExternalTournamentParticipant', { + tournamentId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + groupId: { + type: DataTypes.INTEGER, + autoIncrement: false, + allowNull: true + }, + firstName: { + type: DataTypes.STRING, + allowNull: false, + set(value) { + const encryptedValue = encryptData(value); + this.setDataValue('firstName', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('firstName'); + return decryptData(encryptedValue); + } + }, + lastName: { + type: DataTypes.STRING, + allowNull: false, + set(value) { + const encryptedValue = encryptData(value); + this.setDataValue('lastName', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('lastName'); + return decryptData(encryptedValue); + } + }, + club: { + type: DataTypes.STRING, + allowNull: true, + set(value) { + if (!value) { + this.setDataValue('club', null); + return; + } + const encryptedValue = encryptData(value); + this.setDataValue('club', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('club'); + if (!encryptedValue) return null; + return decryptData(encryptedValue); + } + }, + birthDate: { + type: DataTypes.STRING, + allowNull: true, + set(value) { + if (!value) { + this.setDataValue('birthDate', null); + return; + } + const encryptedValue = encryptData(value || ''); + this.setDataValue('birthDate', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('birthDate'); + if (!encryptedValue) return null; + return decryptData(encryptedValue); + } + }, + seeded: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + classId: { + type: DataTypes.INTEGER, + allowNull: true + } +}, { + underscored: true, + tableName: 'external_tournament_participant', + timestamps: true, +}); + +export default ExternalTournamentParticipant; + diff --git a/backend/models/Tournament.js b/backend/models/Tournament.js index 4b18e07..dca4a85 100644 --- a/backend/models/Tournament.js +++ b/backend/models/Tournament.js @@ -34,6 +34,11 @@ const Tournament = sequelize.define('Tournament', { allowNull: false, defaultValue: 3, }, + allowsExternal: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, }, { underscored: true, tableName: 'tournament', diff --git a/backend/models/TournamentClass.js b/backend/models/TournamentClass.js new file mode 100644 index 0000000..f3b8531 --- /dev/null +++ b/backend/models/TournamentClass.js @@ -0,0 +1,38 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Tournament from './Tournament.js'; + +const TournamentClass = sequelize.define('TournamentClass', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + tournamentId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: Tournament, + key: 'id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + sortOrder: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + } +}, { + underscored: true, + tableName: 'tournament_class', + timestamps: true +}); + +export default TournamentClass; + diff --git a/backend/models/TournamentGroup.js b/backend/models/TournamentGroup.js index f095e77..4a4d2f2 100644 --- a/backend/models/TournamentGroup.js +++ b/backend/models/TournamentGroup.js @@ -12,6 +12,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', { type: DataTypes.INTEGER, allowNull: false }, + classId: { + type: DataTypes.INTEGER, + allowNull: true + }, }, { underscored: true, tableName: 'tournament_group', diff --git a/backend/models/TournamentMatch.js b/backend/models/TournamentMatch.js index 05b1a31..23243c3 100644 --- a/backend/models/TournamentMatch.js +++ b/backend/models/TournamentMatch.js @@ -25,6 +25,10 @@ const TournamentMatch = sequelize.define('TournamentMatch', { onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + classId: { + type: DataTypes.INTEGER, + allowNull: true, + }, groupRound: { type: DataTypes.INTEGER, allowNull: true, diff --git a/backend/models/TournamentMember.js b/backend/models/TournamentMember.js index 827f8af..a8a2c5a 100644 --- a/backend/models/TournamentMember.js +++ b/backend/models/TournamentMember.js @@ -21,6 +21,10 @@ const TournamentMember = sequelize.define('TournamentMember', { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false + }, + classId: { + type: DataTypes.INTEGER, + allowNull: true } }, { underscored: true, diff --git a/backend/models/index.js b/backend/models/index.js index 217421a..34d58cf 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -27,9 +27,11 @@ import Group from './Group.js'; import GroupActivity from './GroupActivity.js'; import Tournament from './Tournament.js'; import TournamentGroup from './TournamentGroup.js'; +import TournamentClass from './TournamentClass.js'; import TournamentMember from './TournamentMember.js'; import TournamentMatch from './TournamentMatch.js'; import TournamentResult from './TournamentResult.js'; +import ExternalTournamentParticipant from './ExternalTournamentParticipant.js'; import Accident from './Accident.js'; import UserToken from './UserToken.js'; import OfficialTournament from './OfficialTournament.js'; @@ -201,6 +203,15 @@ Member.hasMany(TournamentMember, { foreignKey: 'clubMemberId', as: 'tournamentGr TournamentMember.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' }); Tournament.hasMany(TournamentMember, { foreignKey: 'tournamentId', as: 'tournamentMembers' }); +TournamentMember.belongsTo(TournamentClass, { + foreignKey: 'classId', + as: 'class', + constraints: false +}); +TournamentClass.hasMany(TournamentMember, { + foreignKey: 'classId', + as: 'members' +}); TournamentMatch.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' }); Tournament.hasMany(TournamentMatch, { foreignKey: 'tournamentId', as: 'tournamentMatches' }); @@ -227,6 +238,33 @@ TournamentMatch.belongsTo(TournamentMember, { foreignKey: 'player2Id', as: 'play TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player1Id', as: 'player1Matches' }); TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player2Id', as: 'player2Matches' }); +// Tournament Classes +TournamentClass.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' }); +Tournament.hasMany(TournamentClass, { foreignKey: 'tournamentId', as: 'classes' }); + +// External Tournament Participants +ExternalTournamentParticipant.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' }); +Tournament.hasMany(ExternalTournamentParticipant, { foreignKey: 'tournamentId', as: 'externalParticipants' }); +ExternalTournamentParticipant.belongsTo(TournamentGroup, { + foreignKey: 'groupId', + targetKey: 'id', + as: 'group', + constraints: false +}); +TournamentGroup.hasMany(ExternalTournamentParticipant, { + foreignKey: 'groupId', + as: 'externalGroupMembers' +}); +ExternalTournamentParticipant.belongsTo(TournamentClass, { + foreignKey: 'classId', + as: 'class', + constraints: false +}); +TournamentClass.hasMany(ExternalTournamentParticipant, { + foreignKey: 'classId', + as: 'externalParticipants' +}); + Accident.belongsTo(Member, { foreignKey: 'memberId', as: 'members' }); Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' }); @@ -283,9 +321,11 @@ export { GroupActivity, Tournament, TournamentGroup, + TournamentClass, TournamentMember, TournamentMatch, TournamentResult, + ExternalTournamentParticipant, Accident, UserToken, OfficialTournament, diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index 861aae6..8ac35fb 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -23,6 +23,16 @@ import { reopenMatch, deleteKnockoutMatches, setMatchActive, + addExternalParticipant, + getExternalParticipants, + removeExternalParticipant, + updateExternalParticipantSeeded, + getTournamentClasses, + addTournamentClass, + updateTournamentClass, + deleteTournamentClass, + updateParticipantClass, + createGroupsPerClass, } from '../controllers/tournamentController.js'; import { authenticate } from '../middleware/authMiddleware.js'; @@ -36,11 +46,12 @@ router.post('/modus', authenticate, setModus); router.post('/groups/reset', authenticate, resetGroups); router.post('/matches/reset', authenticate, resetMatches); router.put('/groups', authenticate, createGroups); +router.post('/groups/create', authenticate, createGroupsPerClass); router.post('/groups', authenticate, fillGroups); router.get('/groups', authenticate, getGroups); router.post('/match/result', authenticate, addMatchResult); router.delete('/match/result', authenticate, deleteMatchResult); -router.post("/match/reopen", reopenMatch); +router.post("/match/reopen", authenticate, reopenMatch); router.post('/match/finish', authenticate, finishMatch); router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive); router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches); @@ -48,8 +59,21 @@ router.put('/:clubId/:tournamentId', authenticate, updateTournament); router.get('/:clubId/:tournamentId', authenticate, getTournament); router.get('/:clubId', authenticate, getTournaments); router.post('/knockout', authenticate, startKnockout); -router.delete("/matches/knockout", deleteKnockoutMatches); +router.delete("/matches/knockout", authenticate, deleteKnockoutMatches); router.post('/groups/manual', authenticate, manualAssignGroups); router.post('/', authenticate, addTournament); +// Externe Teilnehmer +router.post('/external-participant', authenticate, addExternalParticipant); +router.post('/external-participants', authenticate, getExternalParticipants); +router.delete('/external-participant', authenticate, removeExternalParticipant); +router.put('/external-participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateExternalParticipantSeeded); + +// Tournament Classes +router.get('/classes/:clubId/:tournamentId', authenticate, getTournamentClasses); +router.post('/class/:clubId/:tournamentId', authenticate, addTournamentClass); +router.put('/class/:clubId/:tournamentId/:classId', authenticate, updateTournamentClass); +router.delete('/class/:clubId/:tournamentId/:classId', authenticate, deleteTournamentClass); +router.put('/participant/:clubId/:tournamentId/:participantId/class', authenticate, updateParticipantClass); + export default router; diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index e5f1a0e..32cfa2a 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -5,6 +5,8 @@ import TournamentGroup from "../models/TournamentGroup.js"; import TournamentMatch from "../models/TournamentMatch.js"; import TournamentMember from "../models/TournamentMember.js"; import TournamentResult from "../models/TournamentResult.js"; +import ExternalTournamentParticipant from "../models/ExternalTournamentParticipant.js"; +import TournamentClass from "../models/TournamentClass.js"; import { checkAccess } from '../utils/userUtils.js'; import { Op, literal } from 'sequelize'; @@ -13,20 +15,42 @@ import { devLog } from '../utils/logger.js'; function getRoundName(size) { switch (size) { case 2: return "Finale"; + case 3: return "Halbfinale (3)"; case 4: return "Halbfinale"; + case 5: return "Viertelfinale (5)"; + case 6: return "Viertelfinale (6)"; + case 7: return "Viertelfinale (7)"; case 8: return "Viertelfinale"; + case 9: return "Achtelfinale (9)"; + case 10: return "Achtelfinale (10)"; + case 11: return "Achtelfinale (11)"; + case 12: return "Achtelfinale (12)"; + case 13: return "Achtelfinale (13)"; + case 14: return "Achtelfinale (14)"; + case 15: return "Achtelfinale (15)"; case 16: return "Achtelfinale"; - default: return `Runde der ${size}`; + default: return `${size}-Runde`; } } function nextRoundName(currentName) { - switch (currentName) { - case "Achtelfinale": return "Viertelfinale"; - case "Viertelfinale": return "Halbfinale"; - case "Halbfinale": return "Finale"; - default: return null; + // Erkenne Rundennamen auch mit Suffixen wie "Achtelfinale (9)", "Viertelfinale (5)", etc. + if (currentName && typeof currentName === 'string') { + if (currentName.includes("Achtelfinale")) { + return "Viertelfinale"; + } + if (currentName.includes("Viertelfinale")) { + return "Halbfinale"; + } + if (currentName.includes("Halbfinale")) { + return "Finale"; + } + // Für "Finale" gibt es keine nächste Runde + if (currentName.includes("Finale")) { + return null; + } } + return null; } class TournamentService { // 1. Turniere listen @@ -35,13 +59,13 @@ class TournamentService { const tournaments = await Tournament.findAll({ where: { clubId }, order: [['date', 'DESC']], - attributes: ['id', 'name', 'date'] + attributes: ['id', 'name', 'date', 'allowsExternal'] }); return JSON.parse(JSON.stringify(tournaments)); } // 2. Neues Turnier anlegen (prüft Duplikat) - async addTournament(userToken, clubId, tournamentName, date, winningSets) { + async addTournament(userToken, clubId, tournamentName, date, winningSets, allowsExternal) { await checkAccess(userToken, clubId); const existing = await Tournament.findOne({ where: { clubId, date } }); if (existing) { @@ -53,7 +77,8 @@ class TournamentService { clubId: +clubId, bestOfEndroundSize: 0, type: '', - winningSets: winningSets || 3 // Default: 3 Sätze + winningSets: winningSets || 3, // Default: 3 Sätze + allowsExternal: allowsExternal || false }); return JSON.parse(JSON.stringify(t)); } @@ -107,14 +132,19 @@ class TournamentService { } // 6. Leere Gruppen anlegen - async createGroups(userToken, clubId, tournamentId) { + async createGroups(userToken, clubId, tournamentId, numberOfGroups = null) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } - const existing = await TournamentGroup.findAll({ where: { tournamentId } }); - const desired = tournament.numberOfGroups; + const desired = numberOfGroups !== null ? numberOfGroups : tournament.numberOfGroups; + const existing = await TournamentGroup.findAll({ + where: { + tournamentId, + classId: null // Nur Gruppen ohne Klasse + } + }); // zu viele Gruppen löschen if (existing.length > desired) { const toRemove = existing.slice(desired); @@ -122,7 +152,33 @@ class TournamentService { } // fehlende Gruppen anlegen for (let i = existing.length; i < desired; i++) { - await TournamentGroup.create({ tournamentId }); + await TournamentGroup.create({ tournamentId, classId: null }); + } + } + + async createGroupsPerClass(userToken, clubId, tournamentId, groupsPerClass) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + + // Lösche alle bestehenden Gruppen + await TournamentGroup.destroy({ where: { tournamentId } }); + + // Erstelle Gruppen pro Klasse + for (const [classIdStr, numberOfGroups] of Object.entries(groupsPerClass)) { + const classId = classIdStr === 'null' || classIdStr === 'undefined' ? null : parseInt(classIdStr); + const numGroups = parseInt(numberOfGroups) || 0; + + if (numGroups > 0) { + for (let i = 0; i < numGroups; i++) { + await TournamentGroup.create({ + tournamentId, + classId: classId || null + }); + } + } } } @@ -162,15 +218,17 @@ class TournamentService { throw new Error('Turnier nicht gefunden'); } - // 1) Stelle sicher, dass die richtige Anzahl von Gruppen existiert - await this.createGroups(userToken, clubId, tournamentId); - let groups = await TournamentGroup.findAll({ + // Lade alle Gruppen (mit classId) + let allGroups = await TournamentGroup.findAll({ where: { tournamentId }, - order: [['id', 'ASC']] // Stelle sicher, dass Gruppen sortiert sind + order: [['id', 'ASC']] }); + // Lade alle Teilnehmer (interne und externe) const members = await TournamentMember.findAll({ where: { tournamentId } }); - if (!members.length) { + const externalParticipants = await ExternalTournamentParticipant.findAll({ where: { tournamentId } }); + + if (!members.length && !externalParticipants.length) { throw new Error('Keine Teilnehmer vorhanden.'); } @@ -178,150 +236,230 @@ class TournamentService { await TournamentMatch.destroy({ where: { tournamentId } }); // 3) Alle Zuordnungen löschen und zufällig neu verteilen - // (Bei "Zufällig verteilen" sollen alle alten Zuordnungen gelöscht werden) await TournamentMember.update( { groupId: null }, { where: { tournamentId } } ); - - // 4) Gleichmäßige Verteilung: Zuerst alle Spieler gleichmäßig verteilen, - // dann gesetzte Spieler gleichmäßig umverteilen, um sie gleichmäßig zu verteilen - const seededMembers = members.filter(m => m.seeded); - const unseededMembers = members.filter(m => !m.seeded); - - // Alle Spieler zufällig mischen - const allMembers = members.slice(); - for (let i = allMembers.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [allMembers[i], allMembers[j]] = [allMembers[j], allMembers[i]]; - } - - // Berechne die gewünschte Anzahl pro Gruppe - const totalMembers = allMembers.length; - const numGroups = groups.length; - const baseSize = Math.floor(totalMembers / numGroups); - const remainder = totalMembers % numGroups; - - // Verteile alle Spieler gleichmäßig (round-robin) - const groupAssignments = groups.map(() => []); - allMembers.forEach((m, idx) => { - const targetGroup = idx % numGroups; - groupAssignments[targetGroup].push(m); - }); - - // Jetzt optimiere die Verteilung: Stelle sicher, dass die Gruppen gleichmäßig groß sind - // und die gesetzten Spieler gleichmäßig verteilt sind - - // Zuerst: Stelle sicher, dass die Gruppen gleichmäßig groß sind - // Sortiere Gruppen nach Größe (größte zuerst) - const groupSizes = groupAssignments.map((group, idx) => ({ idx, size: group.length })); - groupSizes.sort((a, b) => b.size - a.size); - - // Verschiebe Spieler von größeren zu kleineren Gruppen - for (let i = 0; i < numGroups - 1; i++) { - const largeGroupIdx = groupSizes[i].idx; - const smallGroupIdx = groupSizes[numGroups - 1].idx; - - while (groupAssignments[largeGroupIdx].length > groupAssignments[smallGroupIdx].length + 1) { - // Verschiebe einen Spieler von der größeren zur kleineren Gruppe - const memberToMove = groupAssignments[largeGroupIdx].pop(); - groupAssignments[smallGroupIdx].push(memberToMove); - } - } - - // Jetzt optimiere die Verteilung der gesetzten Spieler: - // Zähle gesetzte Spieler pro Gruppe - const seededCounts = groupAssignments.map(group => - group.filter(m => m.seeded).length + await ExternalTournamentParticipant.update( + { groupId: null }, + { where: { tournamentId } } ); - - // Verschiebe gesetzte Spieler, um eine gleichmäßigere Verteilung zu erreichen - for (let round = 0; round < 10; round++) { // Maximal 10 Runden für Optimierung - let changed = false; - - // Finde Gruppe mit den meisten gesetzten Spielern - let maxSeededIdx = 0; - let maxSeededCount = seededCounts[0]; - for (let i = 1; i < numGroups; i++) { - if (seededCounts[i] > maxSeededCount) { - maxSeededCount = seededCounts[i]; - maxSeededIdx = i; - } + + // 4) Gruppiere Teilnehmer und Gruppen nach Klassen + const groupsByClass = {}; + allGroups.forEach(g => { + const classKey = g.classId || 'null'; + if (!groupsByClass[classKey]) { + groupsByClass[classKey] = []; } - - // Finde Gruppe mit den wenigsten gesetzten Spielern - let minSeededIdx = 0; - let minSeededCount = seededCounts[0]; - for (let i = 1; i < numGroups; i++) { - if (seededCounts[i] < minSeededCount) { - minSeededCount = seededCounts[i]; - minSeededIdx = i; - } + groupsByClass[classKey].push(g); + }); + + const membersByClass = {}; + members.forEach(m => { + const classKey = (m.classId || 'null').toString(); + if (!membersByClass[classKey]) { + membersByClass[classKey] = []; } - - // Wenn die Differenz größer als 1 ist, verschiebe einen gesetzten Spieler - if (maxSeededCount - minSeededCount > 1) { - // Finde einen gesetzten Spieler in der Gruppe mit den meisten - const seededInMax = groupAssignments[maxSeededIdx].filter(m => m.seeded); - if (seededInMax.length > 0) { - const memberToMove = seededInMax[0]; - // Entferne aus max-Gruppe - groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(m => m.id !== memberToMove.id); - seededCounts[maxSeededIdx]--; - // Füge zu min-Gruppe hinzu - groupAssignments[minSeededIdx].push(memberToMove); - seededCounts[minSeededIdx]++; - changed = true; - } - } - - if (!changed) break; - } - - // Warte auf alle Updates - const updatePromises = []; - groupAssignments.forEach((groupMembers, groupIdx) => { - groupMembers.forEach(member => { - updatePromises.push(member.update({ groupId: groups[groupIdx].id })); - }); + membersByClass[classKey].push({ ...m.toJSON(), isExternal: false }); }); - await Promise.all(updatePromises); + externalParticipants.forEach(e => { + const classKey = (e.classId || 'null').toString(); + if (!membersByClass[classKey]) { + membersByClass[classKey] = []; + } + membersByClass[classKey].push({ ...e.toJSON(), isExternal: true }); + }); - // 5) Round‑Robin anlegen wie gehabt - NUR innerhalb jeder Gruppe - // Stelle sicher, dass Gruppen sortiert sind - const sortedGroups = groups.sort((a, b) => a.id - b.id); + // 5) Für jede Klasse separat verteilen + const allUpdatePromises = []; - for (const g of sortedGroups) { - const gm = await TournamentMember.findAll({ where: { groupId: g.id } }); + for (const [classKey, groups] of Object.entries(groupsByClass)) { + const classMembers = membersByClass[classKey] || []; + if (classMembers.length === 0 || groups.length === 0) continue; + + // Alle Spieler dieser Klasse zufällig mischen + const shuffledMembers = classMembers.slice(); + for (let i = shuffledMembers.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledMembers[i], shuffledMembers[j]] = [shuffledMembers[j], shuffledMembers[i]]; + } - if (gm.length < 2) { - console.log(`⚠️ Gruppe ${g.id} hat weniger als 2 Spieler (${gm.length}), überspringe Spiele-Erstellung`); + // Verteile alle Spieler gleichmäßig (round-robin) + const groupAssignments = groups.map(() => []); + shuffledMembers.forEach((m, idx) => { + const targetGroup = idx % groups.length; + groupAssignments[targetGroup].push(m); + }); + + // Optimiere die Verteilung: Stelle sicher, dass die Gruppen gleichmäßig groß sind + const numGroups = groups.length; + const groupSizes = groupAssignments.map((group, idx) => ({ idx, size: group.length })); + groupSizes.sort((a, b) => b.size - a.size); + + // Verschiebe Spieler von größeren zu kleineren Gruppen + for (let i = 0; i < numGroups - 1; i++) { + const largeGroupIdx = groupSizes[i].idx; + const smallGroupIdx = groupSizes[numGroups - 1].idx; + + while (groupAssignments[largeGroupIdx].length > groupAssignments[smallGroupIdx].length + 1) { + const memberToMove = groupAssignments[largeGroupIdx].pop(); + groupAssignments[smallGroupIdx].push(memberToMove); + } + } + + // Optimiere die Verteilung der gesetzten Spieler + const seededCounts = groupAssignments.map(group => + group.filter(m => m.seeded).length + ); + + for (let round = 0; round < 10; round++) { + let changed = false; + let maxSeededIdx = 0; + let maxSeededCount = seededCounts[0]; + for (let i = 1; i < numGroups; i++) { + if (seededCounts[i] > maxSeededCount) { + maxSeededCount = seededCounts[i]; + maxSeededIdx = i; + } + } + + let minSeededIdx = 0; + let minSeededCount = seededCounts[0]; + for (let i = 1; i < numGroups; i++) { + if (seededCounts[i] < minSeededCount) { + minSeededCount = seededCounts[i]; + minSeededIdx = i; + } + } + + if (maxSeededCount - minSeededCount > 1) { + const seededInMax = groupAssignments[maxSeededIdx].filter(m => m.seeded); + if (seededInMax.length > 0) { + const memberToMove = seededInMax[0]; + groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(m => m.id !== memberToMove.id); + seededCounts[maxSeededIdx]--; + groupAssignments[minSeededIdx].push(memberToMove); + seededCounts[minSeededIdx]++; + changed = true; + } + } + + if (!changed) break; + } + + // Speichere Zuordnungen + groupAssignments.forEach((groupMembers, groupIdx) => { + groupMembers.forEach(member => { + if (member.isExternal) { + allUpdatePromises.push( + ExternalTournamentParticipant.update( + { groupId: groups[groupIdx].id }, + { where: { id: member.id } } + ) + ); + } else { + allUpdatePromises.push( + TournamentMember.update( + { groupId: groups[groupIdx].id }, + { where: { id: member.id } } + ) + ); + } + }); + }); + } + + await Promise.all(allUpdatePromises); + + // 6) Round‑Robin anlegen - NUR innerhalb jeder Gruppe + for (const g of allGroups) { + // Lade interne und externe Teilnehmer der Gruppe + const internalMembers = await TournamentMember.findAll({ where: { groupId: g.id } }); + const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId: g.id } }); + + // Kombiniere beide Listen mit eindeutigen zusammengesetzten Schlüsseln + // um ID-Kollisionen zwischen TournamentMember und ExternalTournamentParticipant zu vermeiden + const allGroupMembers = [ + ...internalMembers.map(m => ({ id: m.id, isExternal: false, key: `internal-${m.id}` })), + ...externalMembers.map(m => ({ id: m.id, isExternal: true, key: `external-${m.id}` })) + ]; + + // Erstelle eine Map für schnelle Suche nach zusammengesetztem Schlüssel + const memberMap = new Map(); + allGroupMembers.forEach(m => { + memberMap.set(m.key, m); + }); + + if (allGroupMembers.length < 2) { continue; } - const rounds = this.generateRoundRobinSchedule(gm); + // Für Round-Robin verwenden wir zusammengesetzte Schlüssel, um ID-Kollisionen zu vermeiden + const rounds = this.generateRoundRobinSchedule(allGroupMembers.map(m => ({ id: m.key }))); for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) { - for (const [p1Id, p2Id] of rounds[roundIndex]) { - // p1Id und p2Id sind bereits aus gm, also müssen sie zur Gruppe g gehören - // Prüfe nur, ob beide IDs vorhanden sind (nicht null, falls Bye) - if (p1Id && p2Id) { - await TournamentMatch.create({ - tournamentId, - groupId: g.id, - round: 'group', - player1Id: p1Id, - player2Id: p2Id, - groupRound: roundIndex + 1 - }); + for (const [p1Key, p2Key] of rounds[roundIndex]) { + if (p1Key && p2Key) { + // Finde die tatsächlichen Teilnehmer mit zusammengesetztem Schlüssel + const p1 = memberMap.get(p1Key); + const p2 = memberMap.get(p2Key); + + if (p1 && p2) { + await TournamentMatch.create({ + tournamentId, + groupId: g.id, + round: 'group', + player1Id: p1.id, + player2Id: p2.id, + groupRound: roundIndex + 1, + classId: g.classId + }); + } } } } } - // 5) Teilnehmer mit Gruppen zurückgeben - return await TournamentMember.findAll({ where: { tournamentId } }); + // Rückgabe: Lade sowohl interne als auch externe Teilnehmer + // Ähnlich wie getGroupsWithParticipants, aber ohne Gruppierung + const internalMembers = await TournamentMember.findAll({ + where: { tournamentId }, + include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }] + }); + + const externalMembers = await ExternalTournamentParticipant.findAll({ + where: { tournamentId } + }); + + // Kombiniere beide Listen für Rückgabe + const allParticipants = [ + ...internalMembers.map(m => ({ + id: m.id, + tournamentId: m.tournamentId, + groupId: m.groupId, + clubMemberId: m.clubMemberId, + seeded: m.seeded, + classId: m.classId, + member: m.member, + isExternal: false + })), + ...externalMembers.map(m => ({ + id: m.id, + tournamentId: m.tournamentId, + groupId: m.groupId, + firstName: m.firstName, + lastName: m.lastName, + club: m.club, + birthDate: m.birthDate, + seeded: m.seeded, + classId: m.classId, + isExternal: true + })) + ]; + + return allParticipants; } async getGroups(userToken, clubId, tournamentId) { @@ -348,20 +486,54 @@ class TournamentService { model: TournamentMember, as: 'tournamentGroupMembers', include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }] + }, { + model: ExternalTournamentParticipant, + as: 'externalGroupMembers' }], - order: [['id', 'ASC']] + order: [ + [literal('CASE WHEN `TournamentGroup`.`class_id` IS NULL THEN 1 ELSE 0 END'), 'ASC'], + [literal('`TournamentGroup`.`class_id`'), 'ASC'], + ['id', 'ASC'] + ] }); - // hier den Index mit aufnehmen: - return groups.map((g, idx) => ({ - groupId: g.id, - groupNumber: idx + 1, // jetzt definiert - participants: g.tournamentGroupMembers.map(m => ({ - id: m.id, - name: `${m.member.firstName} ${m.member.lastName}`, - seeded: m.seeded || false - })) - })); + // Gruppiere nach Klassen und nummeriere Gruppen pro Klasse + const groupsByClass = {}; + groups.forEach(g => { + const classKey = g.classId || 'null'; + if (!groupsByClass[classKey]) { + groupsByClass[classKey] = []; + } + groupsByClass[classKey].push(g); + }); + + const result = []; + for (const [classKey, classGroups] of Object.entries(groupsByClass)) { + classGroups.forEach((g, idx) => { + const internalParticipants = (g.tournamentGroupMembers || []).map(m => ({ + id: m.id, + name: `${m.member.firstName} ${m.member.lastName}`, + seeded: m.seeded || false, + isExternal: false + })); + + const externalParticipants = (g.externalGroupMembers || []).map(m => ({ + id: m.id, + name: `${m.firstName} ${m.lastName}`, + seeded: m.seeded || false, + isExternal: true + })); + + result.push({ + groupId: g.id, + classId: g.classId, + groupNumber: idx + 1, // Nummer innerhalb der Klasse + participants: [...internalParticipants, ...externalParticipants] + }); + }); + } + + return result; } @@ -413,11 +585,11 @@ class TournamentService { await checkAccess(userToken, clubId); const t = await Tournament.findOne({ where: { id: tournamentId, clubId } }); if (!t) throw new Error('Turnier nicht gefunden'); - return await TournamentMatch.findAll({ + const matches = await TournamentMatch.findAll({ where: { tournamentId }, include: [ - { model: TournamentMember, as: 'player1', include: [{ model: Member, as: 'member' }] }, - { model: TournamentMember, as: 'player2', include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'player1', required: false, include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'player2', required: false, include: [{ model: Member, as: 'member' }] }, { model: TournamentResult, as: 'tournamentResults' } ], order: [ @@ -427,6 +599,37 @@ class TournamentService { [{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC'] ] }); + + // Lade externe Teilnehmer für Matches, bei denen player1 oder player2 null ist + const player1Ids = matches.filter(m => !m.player1).map(m => m.player1Id); + const player2Ids = matches.filter(m => !m.player2).map(m => m.player2Id); + const externalPlayerIds = [...new Set([...player1Ids, ...player2Ids])]; + + if (externalPlayerIds.length > 0) { + const externalPlayers = await ExternalTournamentParticipant.findAll({ + where: { + id: { [Op.in]: externalPlayerIds }, + tournamentId + } + }); + + const externalPlayerMap = new Map(); + externalPlayers.forEach(ep => { + externalPlayerMap.set(ep.id, ep); + }); + + // Ersetze null player1/player2 mit externen Teilnehmern + matches.forEach(match => { + if (!match.player1 && externalPlayerMap.has(match.player1Id)) { + match.player1 = externalPlayerMap.get(match.player1Id); + } + if (!match.player2 && externalPlayerMap.has(match.player2Id)) { + match.player2 = externalPlayerMap.get(match.player2Id); + } + }); + } + + return matches; } // 12. Satz-Ergebnis hinzufügen/überschreiben @@ -495,28 +698,46 @@ class TournamentService { match.result = `${win}:${lose}`; await match.save(); + // Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind const allFinished = await TournamentMatch.count({ - where: { tournamentId, round: match.round, isFinished: false } + where: { tournamentId, round: match.round, isFinished: false, classId: match.classId } }) === 0; if (allFinished) { + // Lade alle Matches dieser Runde UND Klasse const sameRound = await TournamentMatch.findAll({ - where: { tournamentId, round: match.round } + where: { tournamentId, round: match.round, classId: match.classId } }); - const winners = sameRound.map(m => { + + // Gruppiere nach Klasse + const winnersByClass = {}; + sameRound.forEach(m => { const [w1, w2] = m.result.split(":").map(n => +n); - return w1 > w2 ? m.player1Id : m.player2Id; + const winner = w1 > w2 ? m.player1Id : m.player2Id; + const classKey = m.classId || 'null'; + if (!winnersByClass[classKey]) { + winnersByClass[classKey] = []; + } + winnersByClass[classKey].push(winner); }); const nextName = nextRoundName(match.round); if (nextName) { - for (let i = 0; i < winners.length / 2; i++) { - await TournamentMatch.create({ - tournamentId, - round: nextName, - player1Id: winners[i], - player2Id: winners[winners.length - 1 - i] - }); + // Erstelle nächste Runde pro Klasse + for (const [classKey, winners] of Object.entries(winnersByClass)) { + if (winners.length < 2) continue; // Überspringe Klassen mit weniger als 2 Gewinnern + + const classId = classKey !== 'null' ? parseInt(classKey) : null; + + for (let i = 0; i < winners.length / 2; i++) { + await TournamentMatch.create({ + tournamentId, + round: nextName, + player1Id: winners[i], + player2Id: winners[winners.length - 1 - i], + classId: classId + }); + } } } } @@ -525,7 +746,10 @@ class TournamentService { async _determineQualifiers(tournamentId, tournament) { const groups = await TournamentGroup.findAll({ where: { tournamentId }, - include: [{ model: TournamentMember, as: "tournamentGroupMembers" }] + include: [ + { model: TournamentMember, as: "tournamentGroupMembers" }, + { model: ExternalTournamentParticipant, as: "externalGroupMembers" } + ] }); const groupMatches = await TournamentMatch.findAll({ where: { tournamentId, round: "group", isFinished: true } @@ -534,8 +758,13 @@ class TournamentService { const qualifiers = []; for (const g of groups) { const stats = {}; - for (const tm of g.tournamentGroupMembers) { - stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0 }; + // Interne Teilnehmer + for (const tm of g.tournamentGroupMembers || []) { + stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, isExternal: false }; + } + // Externe Teilnehmer + for (const ext of g.externalGroupMembers || []) { + stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, isExternal: true }; } for (const m of groupMatches.filter(m => m.groupId === g.id)) { if (!stats[m.player1Id] || !stats[m.player2Id]) continue; @@ -560,7 +789,21 @@ class TournamentService { if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon; return a.member.id - b.member.id; }); - qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map(r => r.member)); + // Füge classId zur Gruppe hinzu + // r.member ist entweder TournamentMember oder ExternalTournamentParticipant + qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map(r => { + const member = r.member; + // Stelle sicher, dass id vorhanden ist + if (!member || !member.id) { + devLog(`[_determineQualifiers] Warning: Member without id found in group ${g.id}`); + return null; + } + return { + id: member.id, + classId: g.classId, + isExternal: r.isExternal || false + }; + }).filter(q => q !== null)); } return qualifiers; } @@ -584,19 +827,60 @@ class TournamentService { const qualifiers = await this._determineQualifiers(tournamentId, t); if (qualifiers.length < 2) throw new Error("Zu wenige Qualifikanten für K.O.-Runde"); + devLog(`[startKnockout] Found ${qualifiers.length} qualifiers`); + qualifiers.forEach((q, idx) => { + devLog(`[startKnockout] Qualifier ${idx}: id=${q.id}, classId=${q.classId}, isExternal=${q.isExternal}`); + }); + await TournamentMatch.destroy({ where: { tournamentId, round: { [Op.ne]: "group" } } }); - const roundSize = qualifiers.length; - const rn = getRoundName(roundSize); - for (let i = 0; i < roundSize / 2; i++) { - await TournamentMatch.create({ - tournamentId, - round: rn, - player1Id: qualifiers[i].id, - player2Id: qualifiers[roundSize - 1 - i].id - }); + // Gruppiere Qualifiers nach Klasse + const qualifiersByClass = {}; + qualifiers.forEach(q => { + const classKey = q.classId || 'null'; + if (!qualifiersByClass[classKey]) { + qualifiersByClass[classKey] = []; + } + qualifiersByClass[classKey].push(q); + }); + + devLog(`[startKnockout] Qualifiers grouped by class:`, Object.keys(qualifiersByClass).map(k => `${k}: ${qualifiersByClass[k].length}`)); + + // Erstelle Matches pro Klasse + for (const [classKey, classQualifiers] of Object.entries(qualifiersByClass)) { + const roundSize = classQualifiers.length; + if (roundSize < 2) continue; // Überspringe Klassen mit weniger als 2 Qualifizierten + + const rn = getRoundName(roundSize); + const classId = classKey !== 'null' ? parseInt(classKey) : null; + + // Berechne die Anzahl der Matches (bei ungerader Anzahl: abrunden) + const numMatches = Math.floor(roundSize / 2); + + for (let i = 0; i < numMatches; i++) { + const player1 = classQualifiers[i]; + const player2 = classQualifiers[roundSize - 1 - i]; + + if (!player1 || !player2 || !player1.id || !player2.id) { + devLog(`[startKnockout] Warning: Invalid qualifier at index ${i} or ${roundSize - 1 - i} for class ${classKey}`); + continue; + } + + try { + await TournamentMatch.create({ + tournamentId, + round: rn, + player1Id: player1.id, + player2Id: player2.id, + classId: classId + }); + } catch (error) { + devLog(`[startKnockout] Error creating match: ${error.message}`); + throw error; + } + } } } async manualAssignGroups( @@ -654,17 +938,41 @@ class TournamentService { groupMap[idx + 1] = grp.id; }); - // 5) Teilnehmer updaten + // 5) Teilnehmer updaten (sowohl interne als auch externe) await Promise.all( - assignments.map(({ participantId, groupNumber }) => { + assignments.map(async ({ participantId, groupNumber }) => { const dbGroupId = groupMap[groupNumber]; if (!dbGroupId) { throw new Error(`Ungültige Gruppen‑Nummer: ${groupNumber}`); } - return TournamentMember.update( - { groupId: dbGroupId }, - { where: { id: participantId } } - ); + + // Prüfe zuerst, ob es ein interner Teilnehmer ist + const internalMember = await TournamentMember.findOne({ + where: { id: participantId, tournamentId } + }); + + if (internalMember) { + // Interner Teilnehmer + return TournamentMember.update( + { groupId: dbGroupId }, + { where: { id: participantId, tournamentId } } + ); + } else { + // Versuche externen Teilnehmer + const externalMember = await ExternalTournamentParticipant.findOne({ + where: { id: participantId, tournamentId } + }); + + if (externalMember) { + // Externer Teilnehmer + return ExternalTournamentParticipant.update( + { groupId: dbGroupId }, + { where: { id: participantId, tournamentId } } + ); + } else { + throw new Error(`Teilnehmer mit ID ${participantId} nicht gefunden`); + } + } }) ); @@ -675,17 +983,31 @@ class TournamentService { model: TournamentMember, as: 'tournamentGroupMembers', include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }] + }, { + model: ExternalTournamentParticipant, + as: 'externalGroupMembers' }], order: [['id', 'ASC']] }); - return groups.map(g => ({ - groupId: g.id, - participants: g.tournamentGroupMembers.map(m => ({ + return groups.map(g => { + const internalParticipants = (g.tournamentGroupMembers || []).map(m => ({ id: m.id, - name: `${m.member.firstName} ${m.member.lastName}` - })) - })); + name: `${m.member.firstName} ${m.member.lastName}`, + isExternal: false + })); + + const externalParticipants = (g.externalGroupMembers || []).map(m => ({ + id: m.id, + name: `${m.firstName} ${m.lastName}`, + isExternal: true + })); + + return { + groupId: g.id, + participants: [...internalParticipants, ...externalParticipants] + }; + }); } // services/tournamentService.js @@ -771,14 +1093,7 @@ class TournamentService { throw new Error("Match nicht gefunden"); } - // Wenn ein Match als aktiv gesetzt wird, setze alle anderen Matches des Turniers auf inaktiv - if (isActive) { - await TournamentMatch.update( - { isActive: false }, - { where: { tournamentId, id: { [Op.ne]: matchId } } } - ); - } - + // Setze den Status für dieses Match (mehrere Matches können gleichzeitig aktiv sein) match.isActive = isActive; await match.save(); } @@ -794,6 +1109,175 @@ class TournamentService { }); } + // Externe Teilnehmer hinzufügen + async addExternalParticipant(userToken, clubId, tournamentId, firstName, lastName, club, birthDate) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + if (!tournament.allowsExternal) { + throw new Error('Dieses Turnier erlaubt keine externen Teilnehmer'); + } + await ExternalTournamentParticipant.create({ + tournamentId, + firstName, + lastName, + club: club || null, + birthDate: birthDate || null, + groupId: null + }); + } + + // Externe Teilnehmer abrufen + async getExternalParticipants(userToken, clubId, tournamentId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + return await ExternalTournamentParticipant.findAll({ + where: { tournamentId }, + order: [['firstName', 'ASC'], ['lastName', 'ASC']] + }); + } + + // Externe Teilnehmer löschen + async removeExternalParticipant(userToken, clubId, tournamentId, participantId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + const participant = await ExternalTournamentParticipant.findOne({ + where: { id: participantId, tournamentId } + }); + if (!participant) { + throw new Error('Externer Teilnehmer nicht gefunden'); + } + await participant.destroy(); + } + + // Gesetzt-Status für externe Teilnehmer aktualisieren + async updateExternalParticipantSeeded(userToken, clubId, tournamentId, participantId, seeded) { + await checkAccess(userToken, clubId); + const participant = await ExternalTournamentParticipant.findOne({ + where: { id: participantId, tournamentId } + }); + if (!participant) { + throw new Error('Externer Teilnehmer nicht gefunden'); + } + participant.seeded = seeded; + await participant.save(); + } + + // Tournament Classes + async getTournamentClasses(userToken, clubId, tournamentId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + return await TournamentClass.findAll({ + where: { tournamentId }, + order: [['sortOrder', 'ASC'], ['name', 'ASC']] + }); + } + + async addTournamentClass(userToken, clubId, tournamentId, name) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + // Finde die höchste sortOrder + const maxSortOrder = await TournamentClass.max('sortOrder', { + where: { tournamentId } + }) || 0; + return await TournamentClass.create({ + tournamentId, + name, + sortOrder: maxSortOrder + 1 + }); + } + + async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + const tournamentClass = await TournamentClass.findOne({ + where: { id: classId, tournamentId } + }); + if (!tournamentClass) { + throw new Error('Klasse nicht gefunden'); + } + if (name !== undefined) tournamentClass.name = name; + if (sortOrder !== undefined) tournamentClass.sortOrder = sortOrder; + await tournamentClass.save(); + return tournamentClass; + } + + async deleteTournamentClass(userToken, clubId, tournamentId, classId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + const tournamentClass = await TournamentClass.findOne({ + where: { id: classId, tournamentId } + }); + if (!tournamentClass) { + throw new Error('Klasse nicht gefunden'); + } + // Setze classId bei allen Teilnehmern auf null + await TournamentMember.update( + { classId: null }, + { where: { tournamentId, classId } } + ); + await ExternalTournamentParticipant.update( + { classId: null }, + { where: { tournamentId, classId } } + ); + await tournamentClass.destroy(); + } + + async updateParticipantClass(userToken, clubId, tournamentId, participantId, classId, isExternal = false) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + if (classId !== null) { + const tournamentClass = await TournamentClass.findOne({ + where: { id: classId, tournamentId } + }); + if (!tournamentClass) { + throw new Error('Klasse nicht gefunden'); + } + } + if (isExternal) { + const participant = await ExternalTournamentParticipant.findOne({ + where: { id: participantId, tournamentId } + }); + if (!participant) { + throw new Error('Externer Teilnehmer nicht gefunden'); + } + participant.classId = classId; + await participant.save(); + } else { + const participant = await TournamentMember.findOne({ + where: { id: participantId, tournamentId } + }); + if (!participant) { + throw new Error('Teilnehmer nicht gefunden'); + } + participant.classId = classId; + await participant.save(); + } + } + } export default new TournamentService(); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 56f8496..9e7c2f5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -95,9 +95,13 @@ 🏆 Interne Turniere - + + 🌐 + Offene Turniere + + 📄 - Offizielle Turniere + Turnierteilnahmen ⚙️ diff --git a/frontend/src/components/PDFGenerator.js b/frontend/src/components/PDFGenerator.js index f0b9ddd..27ab7bb 100644 --- a/frontend/src/components/PDFGenerator.js +++ b/frontend/src/components/PDFGenerator.js @@ -58,6 +58,7 @@ class PDFGenerator { this.pdf.addPage(); this.xPos = this.margin; this.yPos = this.position; + this.cursorY = this.margin; this.isLeftColumn = true; } @@ -606,6 +607,552 @@ class PDFGenerator { this.cursorY = y + 10; } + addTournamentPDF(tournamentName, tournamentDate, groupsByClass, groupRankings, matchesByClassAndGroup, getPlayerName, knockoutRanking, participants, hasKnockoutMatches, knockoutMatches) { + // Header + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(16); + this.pdf.text(tournamentName || 'Turnier', this.margin, this.cursorY); + this.cursorY += 8; + + if (tournamentDate) { + this.pdf.setFont('helvetica', 'normal'); + this.pdf.setFontSize(12); + const formattedDate = new Date(tournamentDate).toLocaleDateString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + this.pdf.text(`Datum: ${formattedDate}`, this.margin, this.cursorY); + this.cursorY += 10; + } + + // 1. Gesamt-Ranking nach Klassen (nur Platz und Spieler) + // knockoutRanking und participants werden als zusätzliche Parameter übergeben + // Verwende K.O.-Ranking nur wenn es vorhanden ist UND K.O.-Runden existieren + const useKnockoutRanking = knockoutRanking && knockoutRanking.length > 0 && hasKnockoutMatches; + this.addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, useKnockoutRanking ? knockoutRanking : null, participants); + + // 2. Gruppen-Matrizen mit Ergebnissen (neue Seite) + this.addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName); + + // 4. Alle Spiele nach Klasse und Gruppe sortiert (inkl. K.O.-Runden) + this.addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches); + } + + addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, knockoutRanking, participants) { + // Wenn K.O.-Runden vorhanden sind, verwende diese für das Ranking + if (knockoutRanking && knockoutRanking.length > 0) { + // Erstelle Mapping von Member-ID zu classId + // Für interne Teilnehmer: member.id -> classId + const memberClassMap = {}; + if (participants) { + participants.forEach(p => { + // Interne Teilnehmer haben ein member-Objekt + if (p.member && p.member.id) { + memberClassMap[p.member.id] = p.classId; + } + }); + } + + // Gruppiere K.O.-Ranking nach Klassen + // entry.classId sollte bereits vorhanden sein (aus extendedRankingList) + const rankingByClass = {}; + knockoutRanking.forEach(entry => { + // Verwende classId direkt aus entry, falls vorhanden + let classId = entry.classId; + + // Fallback: Suche über memberId, falls classId nicht vorhanden + if (classId == null && entry.member) { + const memberId = entry.member.id; + if (memberId && participants) { + // Suche im Mapping + classId = memberClassMap[memberId] || null; + + // Falls nicht gefunden, suche direkt in participants + if (!classId) { + const participant = participants.find(p => + p.member && p.member.id === memberId + ); + if (participant) { + classId = participant.classId; + } + } + } + } + + const classKey = classId != null ? String(classId) : 'null'; + + if (!rankingByClass[classKey]) { + rankingByClass[classKey] = []; + } + + const playerName = entry.member + ? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim() + : getPlayerName(entry.player || entry); + + rankingByClass[classKey].push({ + position: entry.position, + name: playerName + }); + }); + + // Sortiere innerhalb jeder Klasse nach Position + Object.keys(rankingByClass).forEach(classKey => { + rankingByClass[classKey].sort((a, b) => a.position - b.position); + }); + + // Zeige Rankings nach Klassen (sortiert nach classId) + Object.entries(rankingByClass).sort((a, b) => { + const aNum = a[0] === 'null' ? 999999 : parseInt(a[0]); + const bNum = b[0] === 'null' ? 999999 : parseInt(b[0]); + return aNum - bNum; + }).forEach(([classId, players]) => { + const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null; + + // Prüfe ob neue Seite nötig + if (this.cursorY > 250) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Klassen-Überschrift + if (className) { + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text(className, this.margin, this.cursorY); + this.cursorY += 10; + } + + // Zeige Spieler + players.forEach(player => { + if (this.cursorY > 280) { + this.addNewPage(); + this.cursorY = this.margin; + // Klassen-Überschrift erneut anzeigen bei Seitenwechsel + if (className) { + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text(className, this.margin, this.cursorY); + this.cursorY += 10; + } + } + + this.pdf.setFont('helvetica', 'normal'); + this.pdf.setFontSize(11); + const playerText = `${player.position}. ${player.name}`; + this.pdf.text(playerText, this.margin, this.cursorY); + this.cursorY += 7; + }); + + // Abstand nach Klasse + this.cursorY += 5; + }); + } else { + // Fallback: Verwende Gruppen-Rankings (nur wenn keine K.O.-Runden) + // Aber: Wenn es K.O.-Runden gibt, aber rankingList leer ist, bedeutet das, + // dass die K.O.-Runden noch nicht abgeschlossen sind + // In diesem Fall sollten wir trotzdem die Gruppen-Rankings verwenden + Object.entries(groupsByClass).forEach(([classId, groups]) => { + const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null; + + // Prüfe ob neue Seite nötig + if (this.cursorY > 250) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Klassen-Überschrift + if (className) { + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text(className, this.margin, this.cursorY); + this.cursorY += 10; + } + + // Sammle alle Spieler aus allen Gruppen dieser Klasse + // WICHTIG: Zeige alle Spieler, nicht nur die ersten Plätze + const allPlayers = []; + groups.forEach(group => { + const rankings = groupRankings[group.groupId] || []; + rankings.forEach(player => { + allPlayers.push({ + position: player.position, + name: player.name, + seeded: player.seeded + }); + }); + }); + + // Sortiere nach Position (1, 1, 2, 2, 3, 3, etc.) + allPlayers.sort((a, b) => { + if (a.position !== b.position) { + return a.position - b.position; + } + // Bei gleicher Position alphabetisch sortieren + return a.name.localeCompare(b.name); + }); + + // Erstelle einfache Liste: nur Platz und Spieler + allPlayers.forEach(player => { + if (this.cursorY > 280) { + this.addNewPage(); + this.cursorY = this.margin; + // Klassen-Überschrift erneut anzeigen bei Seitenwechsel + if (className) { + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text(className, this.margin, this.cursorY); + this.cursorY += 10; + } + } + + this.pdf.setFont('helvetica', 'normal'); + this.pdf.setFontSize(11); + const playerText = `${player.position}. ${player.seeded ? '★ ' : ''}${player.name}`; + this.pdf.text(playerText, this.margin, this.cursorY); + this.cursorY += 7; + }); + + // Abstand nach Klasse + this.cursorY += 5; + }); + } + } + + addTournamentTables(groupsByClass, groupRankings, getPlayerName) { + // Für jede Klasse + Object.entries(groupsByClass).forEach(([classId, groups]) => { + const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null; + + // Für jede Gruppe + groups.forEach(group => { + if (this.cursorY > 250) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Überschrift + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(12); + let title = `Gruppe ${group.groupNumber}`; + if (className) { + title = `${className} - ${title}`; + } + this.pdf.text(title, this.margin, this.cursorY); + this.cursorY += 8; + + // Tabelle mit Rankings + const rankings = groupRankings[group.groupId] || []; + if (rankings.length > 0) { + const head = [['Platz', 'Spieler', 'Punkte', 'Sätze', 'Diff']]; + const body = rankings.map(p => [ + `${p.position}.`, + (p.seeded ? '★ ' : '') + p.name, + p.points.toString(), + `${p.setsWon}:${p.setsLost}`, + (p.setDiff >= 0 ? '+' : '') + p.setDiff.toString() + ]); + + autoTable(this.pdf, { + startY: this.cursorY, + margin: { left: this.margin, right: this.margin }, + head, + body, + theme: 'grid', + styles: { fontSize: 10 }, + headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' }, + didDrawPage: (data) => { + this.cursorY = data.cursor.y + 10; + } + }); + this.cursorY += 5; + } + }); + }); + } + + addTournamentKnockoutRanking(knockoutRanking, getPlayerName) { + // Prüfe ob neue Seite nötig + if (this.cursorY > 200) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Überschrift + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text('Gesamt-Ranking (K.O.-Runden)', this.margin, this.cursorY); + this.cursorY += 10; + + // Ranking-Tabelle + const head = [['Platz', 'Spieler']]; + const body = knockoutRanking.map(entry => { + const playerName = entry.member + ? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim() + : getPlayerName(entry.player || entry); + return [ + `${entry.position}.`, + playerName + ]; + }); + + autoTable(this.pdf, { + startY: this.cursorY, + margin: { left: this.margin, right: this.margin }, + head, + body, + theme: 'grid', + styles: { fontSize: 11 }, + headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' }, + didDrawPage: (data) => { + this.cursorY = data.cursor.y + 10; + } + }); + this.cursorY += 5; + } + + addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName) { + // Neue Seite für Matrizen + this.addNewPage(); + this.cursorY = this.margin; + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text('Gruppen-Matrizen', this.margin, this.cursorY); + this.cursorY += 10; + + // Für jede Klasse + Object.entries(groupsByClass).forEach(([classId, groups]) => { + const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null; + + // Für jede Gruppe + groups.forEach(group => { + if (this.cursorY > 200) { + this.addNewPage(); + this.cursorY = this.margin; + } + + const rankings = groupRankings[group.groupId] || []; + if (rankings.length === 0) return; + + // Überschrift + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(11); + let title = `Gruppe ${group.groupNumber}`; + if (className) { + title = `${className} - ${title}`; + } + this.pdf.text(title, this.margin, this.cursorY); + this.cursorY += 7; + + // Matrix erstellen + const head = [['', ...rankings.map((p, idx) => `G${String.fromCharCode(96 + group.groupNumber)}${idx + 1}`)]]; + const body = rankings.map((player, idx) => { + const row = [`G${String.fromCharCode(96 + group.groupNumber)}${idx + 1} ${(player.seeded ? '★ ' : '') + player.name}`]; + rankings.forEach((opponent, oppIdx) => { + if (idx === oppIdx) { + row.push('-'); + } else { + // Finde Match-Ergebnis + const match = this.findMatchResult ? this.findMatchResult(player.id, opponent.id, group.groupId) : null; + row.push(match || '-'); + } + }); + return row; + }); + + autoTable(this.pdf, { + startY: this.cursorY, + margin: { left: this.margin, right: this.margin }, + head, + body, + theme: 'grid', + styles: { fontSize: 8 }, + headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' }, + columnStyles: { + 0: { cellWidth: 50 } + }, + didDrawPage: (data) => { + this.cursorY = data.cursor.y + 10; + } + }); + this.cursorY += 5; + }); + }); + } + + addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches = []) { + // Neue Seite für Spiele + this.addNewPage(); + this.cursorY = this.margin; + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(14); + this.pdf.text('Alle Spiele', this.margin, this.cursorY); + this.cursorY += 10; + + // Für jede Klasse + Object.entries(matchesByClassAndGroup).forEach(([classId, groups]) => { + const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null; + + // Für jede Gruppe + Object.entries(groups).forEach(([groupId, matches]) => { + if (matches.length === 0) return; + + if (this.cursorY > 200) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Überschrift + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(11); + let title = `Gruppe ${matches[0].groupNumber || groupId}`; + if (className) { + title = `${className} - ${title}`; + } + this.pdf.text(title, this.margin, this.cursorY); + this.cursorY += 7; + + // Spiele-Tabelle + const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']]; + const body = matches.map(m => [ + m.groupRound?.toString() || '-', + getPlayerName(m.player1), + getPlayerName(m.player2), + m.isFinished ? (m.result || '-') : '-', + this.formatSets(m) + ]); + + autoTable(this.pdf, { + startY: this.cursorY, + margin: { left: this.margin, right: this.margin }, + head, + body, + theme: 'grid', + styles: { fontSize: 9 }, + headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' }, + didDrawPage: (data) => { + this.cursorY = data.cursor.y + 10; + } + }); + this.cursorY += 5; + }); + }); + + // K.O.-Runden hinzufügen (wenn vorhanden) + if (knockoutMatches && knockoutMatches.length > 0) { + // Gruppiere K.O.-Matches nach Klassen + const knockoutMatchesByClass = {}; + knockoutMatches.forEach(match => { + const classKey = match.classId != null ? String(match.classId) : 'null'; + if (!knockoutMatchesByClass[classKey]) { + knockoutMatchesByClass[classKey] = []; + } + knockoutMatchesByClass[classKey].push(match); + }); + + // Sortiere Klassen + const sortedClasses = Object.keys(knockoutMatchesByClass).sort((a, b) => { + const aNum = a === 'null' ? 999999 : parseInt(a); + const bNum = b === 'null' ? 999999 : parseInt(b); + return aNum - bNum; + }); + + // Für jede Klasse + sortedClasses.forEach(classKey => { + const classMatches = knockoutMatchesByClass[classKey]; + const className = classKey !== 'null' && classKey !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classKey) : null; + + // Sortiere Matches nach Runde (frühere Runden zuerst: Achtelfinale, Viertelfinale, Halbfinale, Finale) + const getRoundType = (roundName) => { + if (!roundName) return 999; + if (roundName.includes('Achtelfinale')) return 0; + if (roundName.includes('Viertelfinale')) return 1; + if (roundName.includes('Halbfinale')) return 2; + if (roundName.includes('Finale')) return 3; + // Für Runden wie "6-Runde", "8-Runde" etc. - extrahiere die Zahl + const numberMatch = roundName.match(/(\d+)-Runde/); + if (numberMatch) { + const num = parseInt(numberMatch[1]); + // Größere Zahlen = frühere Runden, also umgekehrt sortieren + return -num; // Negativ, damit größere Zahlen zuerst kommen + } + return 999; // Unbekannte Runden zuletzt + }; + + classMatches.sort((a, b) => { + const aRoundType = getRoundType(a.round); + const bRoundType = getRoundType(b.round); + if (aRoundType !== bRoundType) { + return aRoundType - bRoundType; + } + // Wenn gleicher Typ, alphabetisch sortieren + return (a.round || '').localeCompare(b.round || ''); + }); + + if (this.cursorY > 200) { + this.addNewPage(); + this.cursorY = this.margin; + } + + // Überschrift + this.pdf.setFont('helvetica', 'bold'); + this.pdf.setFontSize(11); + let title = 'K.-o.-Runde'; + if (className) { + title = `${className} - ${title}`; + } + this.pdf.text(title, this.margin, this.cursorY); + this.cursorY += 7; + + // Spiele-Tabelle + const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']]; + const body = classMatches.map(m => [ + m.round || '-', + getPlayerName(m.player1), + getPlayerName(m.player2), + m.isFinished ? (m.result || '-') : '-', + this.formatSets(m) + ]); + + autoTable(this.pdf, { + startY: this.cursorY, + margin: { left: this.margin, right: this.margin }, + head, + body, + theme: 'grid', + styles: { fontSize: 9 }, + headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' }, + didDrawPage: (data) => { + this.cursorY = data.cursor.y + 10; + } + }); + this.cursorY += 5; + }); + } + } + + getClassNameForId(classId, groupsByClass) { + // Versuche, den Klassennamen zu finden + // Dies sollte von außen übergeben werden, aber als Fallback: + return `Klasse ${classId}`; + } + + findMatchResult(player1Id, player2Id, groupId) { + // Diese Methode sollte von außen übergeben werden + // Für jetzt: Rückgabe von null + return null; + } + + formatSets(match) { + if (!match.tournamentResults || match.tournamentResults.length === 0) { + return '-'; + } + return match.tournamentResults + .sort((a, b) => a.set - b.set) + .map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`) + .join(', '); + } + } export default PDFGenerator; diff --git a/frontend/src/router.js b/frontend/src/router.js index 1f9e825..1b5867a 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -10,6 +10,7 @@ import DiaryView from './views/DiaryView.vue'; import PendingApprovalsView from './views/PendingApprovalsView.vue'; import ScheduleView from './views/ScheduleView.vue'; import TournamentsView from './views/TournamentsView.vue'; +import ExternalTournamentsView from './views/ExternalTournamentsView.vue'; import TrainingStatsView from './views/TrainingStatsView.vue'; import ClubSettings from './views/ClubSettings.vue'; import PredefinedActivities from './views/PredefinedActivities.vue'; @@ -33,7 +34,8 @@ const routes = [ { path: '/diary', component: DiaryView }, { path: '/pending-approvals', component: PendingApprovalsView}, { path: '/schedule', component: ScheduleView}, - { path: '/tournaments', component: TournamentsView }, + { path: '/tournaments', component: TournamentsView, props: { allowsExternal: false } }, + { path: '/external-tournaments', component: ExternalTournamentsView }, { path: '/training-stats', component: TrainingStatsView }, { path: '/club-settings', component: ClubSettings }, { path: '/predefined-activities', component: PredefinedActivities }, diff --git a/frontend/src/views/ExternalTournamentsView.vue b/frontend/src/views/ExternalTournamentsView.vue new file mode 100644 index 0000000..36d1826 --- /dev/null +++ b/frontend/src/views/ExternalTournamentsView.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index 1a76c59..520ac30 100644 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -1,6 +1,6 @@