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