From dc2c60cefe0249afb2c686ebb19801fe875230dd Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 29 Nov 2025 00:15:01 +0100 Subject: [PATCH] Implement tournament pairing functionality and enhance participant management - Introduced new endpoints for managing tournament pairings, including creating, updating, and deleting pairings. - Updated the tournament service to handle pairing logic, ensuring validation for participants and preventing duplicate pairings. - Enhanced participant management by adding class-based checks for gender and age restrictions when adding participants. - Updated the tournament controller and routes to support the new pairing features and improved participant handling. - Added localization support for new UI elements related to pairings in the frontend, enhancing user experience. --- backend/controllers/tournamentController.js | 99 +- ...der_to_external_tournament_participant.sql | 8 + .../add_gender_to_tournament_class.sql | 8 + .../add_is_doubles_to_tournament_class.sql | 7 + ...add_max_birth_year_to_tournament_class.sql | 27 + .../create_tournament_pairing_table.sql | 33 + ...ename_max_birth_year_to_min_birth_year.sql | 41 + .../models/ExternalTournamentParticipant.js | 5 + backend/models/TournamentClass.js | 17 + backend/models/TournamentPairing.js | 71 + backend/models/index.js | 37 + backend/routes/tournamentRoutes.js | 10 + backend/server.js | 51 +- backend/services/tournamentService.js | 1186 +++++++-- .../tournament/TournamentClassList.vue | 377 +++ .../tournament/TournamentClassSelector.vue | 38 + .../tournament/TournamentConfigTab.vue | 147 ++ .../tournament/TournamentGroupsTab.vue | 349 +++ .../tournament/TournamentParticipantsTab.vue | 630 +++++ .../tournament/TournamentResultsTab.vue | 413 ++++ frontend/src/i18n/locales/de.json | 27 +- frontend/src/services/socketService.js | 13 +- frontend/src/views/TournamentTab.vue | 2119 ++++++++++------- 23 files changed, 4613 insertions(+), 1100 deletions(-) create mode 100644 backend/migrations/add_gender_to_external_tournament_participant.sql create mode 100644 backend/migrations/add_gender_to_tournament_class.sql create mode 100644 backend/migrations/add_is_doubles_to_tournament_class.sql create mode 100644 backend/migrations/add_max_birth_year_to_tournament_class.sql create mode 100644 backend/migrations/create_tournament_pairing_table.sql create mode 100644 backend/migrations/rename_max_birth_year_to_min_birth_year.sql create mode 100644 backend/models/TournamentPairing.js create mode 100644 frontend/src/components/tournament/TournamentClassList.vue create mode 100644 frontend/src/components/tournament/TournamentClassSelector.vue create mode 100644 frontend/src/components/tournament/TournamentConfigTab.vue create mode 100644 frontend/src/components/tournament/TournamentGroupsTab.vue create mode 100644 frontend/src/components/tournament/TournamentParticipantsTab.vue create mode 100644 frontend/src/components/tournament/TournamentResultsTab.vue diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index e735b83..ab06d55 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -1,6 +1,7 @@ // controllers/tournamentController.js import tournamentService from "../services/tournamentService.js"; import { emitTournamentChanged } from '../services/socketService.js'; +import TournamentClass from '../models/TournamentClass.js'; // 1. Alle Turniere eines Vereins export const getTournaments = async (req, res) => { @@ -32,18 +33,26 @@ export const addTournament = async (req, res) => { } }; -// 3. Teilnehmer hinzufügen +// 3. Teilnehmer hinzufügen - klassengebunden export const addParticipant = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId, participant: participantId } = req.body; + const { clubId, classId, participant: participantId } = req.body; try { if (!participantId) { return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' }); } - await tournamentService.addParticipant(token, clubId, tournamentId, participantId); - const participants = await tournamentService.getParticipants(token, clubId, tournamentId); + if (!classId) { + return res.status(400).json({ error: 'Klasse ist erforderlich' }); + } + await tournamentService.addParticipant(token, clubId, classId, participantId); + // Hole tournamentId über die Klasse + const tournamentClass = await TournamentClass.findByPk(classId); + if (!tournamentClass) { + return res.status(404).json({ error: 'Klasse nicht gefunden' }); + } + const participants = await tournamentService.getParticipants(token, clubId, tournamentClass.tournamentId, classId); // Emit Socket-Event - emitTournamentChanged(clubId, tournamentId); + emitTournamentChanged(clubId, tournamentClass.tournamentId); res.status(200).json(participants); } catch (error) { console.error('[addParticipant] Error:', error); @@ -51,12 +60,12 @@ export const addParticipant = async (req, res) => { } }; -// 4. Teilnehmerliste abrufen +// 4. Teilnehmerliste abrufen - nach Klasse oder Turnier export const getParticipants = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId } = req.body; + const { clubId, tournamentId, classId } = req.body; try { - const participants = await tournamentService.getParticipants(token, clubId, tournamentId); + const participants = await tournamentService.getParticipants(token, clubId, tournamentId, classId || null); res.status(200).json(participants); } catch (error) { console.error(error); @@ -401,9 +410,9 @@ export const setMatchActive = async (req, res) => { // Externe Teilnehmer hinzufügen export const addExternalParticipant = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId, firstName, lastName, club, birthDate } = req.body; + const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body; try { - await tournamentService.addExternalParticipant(token, clubId, tournamentId, firstName, lastName, club, birthDate); + await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender); emitTournamentChanged(clubId, tournamentId); res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' }); } catch (error) { @@ -412,12 +421,12 @@ export const addExternalParticipant = async (req, res) => { } }; -// Externe Teilnehmer abrufen +// Externe Teilnehmer abrufen - nach Klasse oder Turnier export const getExternalParticipants = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId } = req.body; + const { clubId, tournamentId, classId } = req.body; try { - const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId); + const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId, classId || null); res.status(200).json(participants); } catch (error) { console.error('[getExternalParticipants] Error:', error); @@ -470,9 +479,9 @@ export const getTournamentClasses = async (req, res) => { export const addTournamentClass = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId } = req.params; - const { name } = req.body; + const { name, isDoubles, gender, minBirthYear } = req.body; try { - const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name); + const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear); emitTournamentChanged(clubId, tournamentId); res.status(200).json(tournamentClass); } catch (error) { @@ -484,9 +493,11 @@ export const addTournamentClass = async (req, res) => { export const updateTournamentClass = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId, classId } = req.params; - const { name, sortOrder } = req.body; + const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body; try { - const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder); + console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear }); + const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear); + console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2)); emitTournamentChanged(clubId, tournamentId); res.status(200).json(tournamentClass); } catch (error) { @@ -521,4 +532,58 @@ export const updateParticipantClass = async (req, res) => { res.status(500).json({ error: error.message }); } }; + +// Tournament Pairings +export const getPairings = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, classId } = req.params; + try { + const pairings = await tournamentService.getPairings(token, clubId, tournamentId, classId); + res.status(200).json(pairings); + } catch (error) { + console.error('[getPairings] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const createPairing = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, classId } = req.params; + const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body; + try { + const pairing = await tournamentService.createPairing(token, clubId, tournamentId, classId, player1Type, player1Id, player2Type, player2Id, seeded, groupId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json(pairing); + } catch (error) { + console.error('[createPairing] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const updatePairing = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, pairingId } = req.params; + const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body; + try { + const pairing = await tournamentService.updatePairing(token, clubId, tournamentId, pairingId, player1Type, player1Id, player2Type, player2Id, seeded, groupId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json(pairing); + } catch (error) { + console.error('[updatePairing] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const deletePairing = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, pairingId } = req.params; + try { + await tournamentService.deletePairing(token, clubId, tournamentId, pairingId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ message: 'Paarung gelöscht' }); + } catch (error) { + console.error('[deletePairing] Error:', error); + res.status(500).json({ error: error.message }); + } +}; \ No newline at end of file diff --git a/backend/migrations/add_gender_to_external_tournament_participant.sql b/backend/migrations/add_gender_to_external_tournament_participant.sql new file mode 100644 index 0000000..cbe71b6 --- /dev/null +++ b/backend/migrations/add_gender_to_external_tournament_participant.sql @@ -0,0 +1,8 @@ +-- Migration: Geschlecht zu externen Turnierteilnehmern hinzufügen +-- Datum: 2025-01-XX + +ALTER TABLE `external_tournament_participant` +ADD COLUMN `gender` ENUM('male', 'female', 'diverse', 'unknown') NULL DEFAULT 'unknown' AFTER `birth_date`; + + + diff --git a/backend/migrations/add_gender_to_tournament_class.sql b/backend/migrations/add_gender_to_tournament_class.sql new file mode 100644 index 0000000..e93e5cf --- /dev/null +++ b/backend/migrations/add_gender_to_tournament_class.sql @@ -0,0 +1,8 @@ +-- Migration: Geschlecht zu Turnierklassen hinzufügen +-- Datum: 2025-01-XX + +ALTER TABLE `tournament_class` +ADD COLUMN `gender` ENUM('male', 'female', 'mixed') NULL DEFAULT NULL AFTER `is_doubles`; + + + diff --git a/backend/migrations/add_is_doubles_to_tournament_class.sql b/backend/migrations/add_is_doubles_to_tournament_class.sql new file mode 100644 index 0000000..707193c --- /dev/null +++ b/backend/migrations/add_is_doubles_to_tournament_class.sql @@ -0,0 +1,7 @@ +-- Migration: Add is_doubles column to tournament_class table +-- Date: 2025-01-23 +-- For MariaDB/MySQL + +ALTER TABLE `tournament_class` +ADD COLUMN `is_doubles` TINYINT(1) NOT NULL DEFAULT 0 AFTER `sort_order`; + diff --git a/backend/migrations/add_max_birth_year_to_tournament_class.sql b/backend/migrations/add_max_birth_year_to_tournament_class.sql new file mode 100644 index 0000000..d754b64 --- /dev/null +++ b/backend/migrations/add_max_birth_year_to_tournament_class.sql @@ -0,0 +1,27 @@ +-- Migration: Geburtsjahr-Beschränkung zu Turnierklassen hinzufügen +-- Datum: 2025-01-XX +-- Beschreibung: Fügt max_birth_year Feld hinzu für "geboren im Jahr X oder früher" (<=) +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament_class'; +SET @columnname = 'max_birth_year'; + +-- 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_class` ADD COLUMN `max_birth_year` INT(11) NULL DEFAULT NULL AFTER `gender`', + 'SELECT 1 AS column_already_exists' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/create_tournament_pairing_table.sql b/backend/migrations/create_tournament_pairing_table.sql new file mode 100644 index 0000000..5d7d72a --- /dev/null +++ b/backend/migrations/create_tournament_pairing_table.sql @@ -0,0 +1,33 @@ +-- Migration: Create tournament_pairing table +-- Date: 2025-01-23 +-- For MariaDB/MySQL + +CREATE TABLE IF NOT EXISTS `tournament_pairing` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `tournament_id` INT(11) NOT NULL, + `class_id` INT(11) NOT NULL, + `group_id` INT(11) NULL, + `member1_id` INT(11) NULL, + `external1_id` INT(11) NULL, + `member2_id` INT(11) NULL, + `external2_id` INT(11) NULL, + `seeded` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `tournament_id` (`tournament_id`), + KEY `class_id` (`class_id`), + KEY `group_id` (`group_id`), + KEY `member1_id` (`member1_id`), + KEY `member2_id` (`member2_id`), + KEY `external1_id` (`external1_id`), + KEY `external2_id` (`external2_id`), + CONSTRAINT `tournament_pairing_ibfk_1` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `tournament_pairing_ibfk_2` FOREIGN KEY (`class_id`) REFERENCES `tournament_class` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `tournament_pairing_ibfk_3` 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/rename_max_birth_year_to_min_birth_year.sql b/backend/migrations/rename_max_birth_year_to_min_birth_year.sql new file mode 100644 index 0000000..a1d4563 --- /dev/null +++ b/backend/migrations/rename_max_birth_year_to_min_birth_year.sql @@ -0,0 +1,41 @@ +-- Migration: Umbenennen von max_birth_year zu min_birth_year +-- Datum: 2025-01-XX +-- Beschreibung: Ändert die Logik von "geboren <= X" zu "geboren >= X" +-- For MariaDB/MySQL + +SET @dbname = DATABASE(); +SET @tablename = 'tournament_class'; +SET @oldcolumnname = 'max_birth_year'; +SET @newcolumnname = 'min_birth_year'; + +-- Check if old column exists +SET @old_column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @oldcolumnname) +); + +-- Check if new column already exists +SET @new_column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (TABLE_SCHEMA = @dbname) + AND (TABLE_NAME = @tablename) + AND (COLUMN_NAME = @newcolumnname) +); + +-- Rename column if old exists and new doesn't +SET @sql = IF(@old_column_exists > 0 AND @new_column_exists = 0, + CONCAT('ALTER TABLE `', @tablename, '` CHANGE COLUMN `', @oldcolumnname, '` `', @newcolumnname, '` INT(11) NULL DEFAULT NULL AFTER `gender`'), + IF(@new_column_exists > 0, + 'SELECT 1 AS column_already_renamed', + 'SELECT 1 AS old_column_not_found' + ) +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/backend/models/ExternalTournamentParticipant.js b/backend/models/ExternalTournamentParticipant.js index cc82f5f..49dbeb7 100644 --- a/backend/models/ExternalTournamentParticipant.js +++ b/backend/models/ExternalTournamentParticipant.js @@ -70,6 +70,11 @@ const ExternalTournamentParticipant = sequelize.define('ExternalTournamentPartic return decryptData(encryptedValue); } }, + gender: { + type: DataTypes.ENUM('male', 'female', 'diverse', 'unknown'), + allowNull: true, + defaultValue: 'unknown' + }, seeded: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/models/TournamentClass.js b/backend/models/TournamentClass.js index f3b8531..f3c2221 100644 --- a/backend/models/TournamentClass.js +++ b/backend/models/TournamentClass.js @@ -27,6 +27,23 @@ const TournamentClass = sequelize.define('TournamentClass', { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 + }, + isDoubles: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + gender: { + type: DataTypes.ENUM('male', 'female', 'mixed'), + allowNull: true, + defaultValue: null + }, + minBirthYear: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + field: 'min_birth_year', + comment: 'Geboren im Jahr X oder später (>=)' } }, { underscored: true, diff --git a/backend/models/TournamentPairing.js b/backend/models/TournamentPairing.js new file mode 100644 index 0000000..751edbc --- /dev/null +++ b/backend/models/TournamentPairing.js @@ -0,0 +1,71 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Tournament from './Tournament.js'; +import TournamentClass from './TournamentClass.js'; + +const TournamentPairing = sequelize.define('TournamentPairing', { + 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' + }, + classId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: TournamentClass, + key: 'id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + groupId: { + type: DataTypes.INTEGER, + allowNull: true + }, + // Player 1: entweder Mitglied oder externer Teilnehmer + member1Id: { + type: DataTypes.INTEGER, + allowNull: true + }, + external1Id: { + type: DataTypes.INTEGER, + allowNull: true + }, + // Player 2: entweder Mitglied oder externer Teilnehmer + member2Id: { + type: DataTypes.INTEGER, + allowNull: true + }, + external2Id: { + type: DataTypes.INTEGER, + allowNull: true + }, + seeded: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } +}, { + underscored: true, + tableName: 'tournament_pairing', + timestamps: true +}); + +export default TournamentPairing; + + + + + diff --git a/backend/models/index.js b/backend/models/index.js index ad9253f..9a8dcef 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -32,6 +32,7 @@ import TournamentMember from './TournamentMember.js'; import TournamentMatch from './TournamentMatch.js'; import TournamentResult from './TournamentResult.js'; import ExternalTournamentParticipant from './ExternalTournamentParticipant.js'; +import TournamentPairing from './TournamentPairing.js'; import Accident from './Accident.js'; import UserToken from './UserToken.js'; import OfficialTournament from './OfficialTournament.js'; @@ -269,6 +270,41 @@ TournamentClass.hasMany(ExternalTournamentParticipant, { as: 'externalParticipants' }); +// Tournament Pairings +TournamentPairing.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' }); +Tournament.hasMany(TournamentPairing, { foreignKey: 'tournamentId', as: 'pairings' }); +TournamentPairing.belongsTo(TournamentClass, { foreignKey: 'classId', as: 'class' }); +TournamentClass.hasMany(TournamentPairing, { foreignKey: 'classId', as: 'pairings' }); +TournamentPairing.belongsTo(TournamentGroup, { + foreignKey: 'groupId', + as: 'group', + constraints: false +}); +TournamentGroup.hasMany(TournamentPairing, { + foreignKey: 'groupId', + as: 'pairings' +}); +TournamentPairing.belongsTo(TournamentMember, { + foreignKey: 'member1Id', + as: 'member1', + constraints: false +}); +TournamentPairing.belongsTo(TournamentMember, { + foreignKey: 'member2Id', + as: 'member2', + constraints: false +}); +TournamentPairing.belongsTo(ExternalTournamentParticipant, { + foreignKey: 'external1Id', + as: 'external1', + constraints: false +}); +TournamentPairing.belongsTo(ExternalTournamentParticipant, { + foreignKey: 'external2Id', + as: 'external2', + constraints: false +}); + Accident.belongsTo(Member, { foreignKey: 'memberId', as: 'members' }); Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' }); @@ -355,6 +391,7 @@ export { TournamentMatch, TournamentResult, ExternalTournamentParticipant, + TournamentPairing, Accident, UserToken, OfficialTournament, diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index d2324aa..ece09eb 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -34,6 +34,10 @@ import { updateParticipantClass, createGroupsPerClass, assignParticipantToGroup, + getPairings, + createPairing, + updatePairing, + deletePairing, } from '../controllers/tournamentController.js'; import { authenticate } from '../middleware/authMiddleware.js'; @@ -78,4 +82,10 @@ router.put('/class/:clubId/:tournamentId/:classId', authenticate, updateTourname router.delete('/class/:clubId/:tournamentId/:classId', authenticate, deleteTournamentClass); router.put('/participant/:clubId/:tournamentId/:participantId/class', authenticate, updateParticipantClass); +// Tournament Pairings +router.get('/pairings/:clubId/:tournamentId/:classId', authenticate, getPairings); +router.post('/pairing/:clubId/:tournamentId/:classId', authenticate, createPairing); +router.put('/pairing/:clubId/:tournamentId/:pairingId', authenticate, updatePairing); +router.delete('/pairing/:clubId/:tournamentId/:pairingId', authenticate, deletePairing); + export default router; diff --git a/backend/server.js b/backend/server.js index 1fde812..4a0e710 100644 --- a/backend/server.js +++ b/backend/server.js @@ -322,25 +322,40 @@ app.use((err, req, res, next) => { // Erstelle HTTPS-Server für Socket.IO (direkt mit SSL) const httpsPort = process.env.HTTPS_PORT || 3051; - try { - const httpsOptions = { - key: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'), - cert: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem') - }; - - const httpsServer = https.createServer(httpsOptions, app); - - // Initialisiere Socket.IO auf HTTPS-Server - initializeSocketIO(httpsServer); - - httpsServer.listen(httpsPort, '0.0.0.0', () => { - console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`); - }); - } catch (err) { - console.error('⚠️ HTTPS-Server konnte nicht gestartet werden:', err.message); - console.log(' → Socket.IO läuft auf HTTP-Server (nur für Entwicklung)'); - // Fallback: Socket.IO auf HTTP-Server + let socketIOInitialized = false; + + // Prüfe, ob SSL-Zertifikate vorhanden sind + const sslKeyPath = '/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'; + const sslCertPath = '/etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem'; + + if (fs.existsSync(sslKeyPath) && fs.existsSync(sslCertPath)) { + try { + const httpsOptions = { + key: fs.readFileSync(sslKeyPath), + cert: fs.readFileSync(sslCertPath) + }; + + const httpsServer = https.createServer(httpsOptions, app); + + // Initialisiere Socket.IO auf HTTPS-Server + initializeSocketIO(httpsServer); + socketIOInitialized = true; + + httpsServer.listen(httpsPort, '0.0.0.0', () => { + console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`); + }); + } catch (err) { + console.error('⚠️ HTTPS-Server konnte nicht gestartet werden:', err.message); + console.log(' → Socket.IO läuft auf HTTP-Server (nur für Entwicklung)'); + } + } else { + console.log('ℹ️ SSL-Zertifikate nicht gefunden - Socket.IO läuft auf HTTP-Server (nur für Entwicklung)'); + } + + // Fallback: Socket.IO auf HTTP-Server (wenn noch nicht initialisiert) + if (!socketIOInitialized) { initializeSocketIO(httpServer); + console.log(' ✅ Socket.IO erfolgreich auf HTTP-Server initialisiert'); } } catch (err) { console.error('Unable to synchronize the database:', err); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 58b4d47..d33c8e0 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -7,6 +7,7 @@ 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 TournamentPairing from "../models/TournamentPairing.js"; import { checkAccess } from '../utils/userUtils.js'; import { Op, literal } from 'sequelize'; @@ -83,39 +84,97 @@ class TournamentService { return JSON.parse(JSON.stringify(t)); } - // 3. Teilnehmer hinzufügen (kein Duplikat) - async addParticipant(userToken, clubId, tournamentId, participantId) { + // 3. Teilnehmer hinzufügen (kein Duplikat) - klassengebunden + async addParticipant(userToken, clubId, classId, participantId) { await checkAccess(userToken, clubId); - const tournament = await Tournament.findByPk(tournamentId); + if (!classId) { + throw new Error('Klasse ist erforderlich'); + } + const tournamentClass = await TournamentClass.findByPk(classId); + if (!tournamentClass) { + throw new Error('Klasse nicht gefunden'); + } + const tournament = await Tournament.findByPk(tournamentClass.tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } + + // Prüfe Geschlecht: Lade Mitglied + const member = await Member.findByPk(participantId); + if (!member) { + throw new Error('Mitglied nicht gefunden'); + } + const memberGender = member.gender || 'unknown'; + + // Validierung: Geschlecht muss zur Klasse passen + if (tournamentClass.gender) { + if (tournamentClass.gender === 'male' && memberGender !== 'male') { + throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); + } + if (tournamentClass.gender === 'female' && memberGender !== 'female') { + throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); + } + if (tournamentClass.gender === 'mixed' && memberGender === 'unknown') { + throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); + } + // mixed erlaubt alle Geschlechter (male, female, diverse) + } + + // Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=) + if (tournamentClass.minBirthYear && member.birthDate) { + // Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY) + let birthYear = null; + if (member.birthDate.includes('-')) { + // Format: YYYY-MM-DD + birthYear = parseInt(member.birthDate.split('-')[0]); + } else if (member.birthDate.includes('.')) { + // Format: DD.MM.YYYY + const parts = member.birthDate.split('.'); + if (parts.length === 3) { + birthYear = parseInt(parts[2]); + } + } + + if (birthYear && !isNaN(birthYear)) { + // Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear + if (birthYear < tournamentClass.minBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); + } + } + } + + // Prüfe, ob Teilnehmer bereits in dieser Klasse ist const exists = await TournamentMember.findOne({ - where: { tournamentId, clubMemberId: participantId } + where: { classId, clubMemberId: participantId } }); if (exists) { - throw new Error('Teilnehmer bereits hinzugefügt'); + throw new Error('Teilnehmer bereits in dieser Klasse hinzugefügt'); } await TournamentMember.create({ - tournamentId, + tournamentId: tournamentClass.tournamentId, + classId, clubMemberId: participantId, groupId: null }); } - // 4. Teilnehmerliste - async getParticipants(userToken, clubId, tournamentId) { + // 4. Teilnehmerliste - nach Klasse oder Turnier + async getParticipants(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } + const whereClause = { tournamentId }; + if (classId !== null) { + whereClause.classId = classId; + } return await TournamentMember.findAll({ - where: { tournamentId }, + where: whereClause, include: [{ model: Member, as: 'member', - attributes: ['id', 'firstName', 'lastName', 'ttr', 'qttr'], + attributes: ['id', 'firstName', 'lastName', 'ttr', 'qttr', 'gender'], }], order: [[{ model: Member, as: 'member' }, 'firstName', 'ASC']] }); @@ -244,6 +303,11 @@ class TournamentService { { groupId: null }, { where: { tournamentId } } ); + // Setze auch groupId bei Paarungen auf null + await TournamentPairing.update( + { groupId: null }, + { where: { tournamentId } } + ); // 4) Gruppiere Teilnehmer und Gruppen nach Klassen const groupsByClass = {}; @@ -279,93 +343,199 @@ class TournamentService { 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--) { + // Prüfe, ob diese Klasse ein Doppel ist + const classId = classKey === 'null' ? null : parseInt(classKey); + const tournamentClass = classId ? await TournamentClass.findOne({ + where: { id: classId, tournamentId } + }) : null; + const isDoubles = tournamentClass ? tournamentClass.isDoubles : false; + + let itemsToDistribute = []; + + if (isDoubles) { + // Bei Doppel: Lade Paarungen und verteile diese + const pairings = await TournamentPairing.findAll({ + where: { tournamentId, classId: classId }, + include: [ + { model: TournamentMember, as: 'member1', include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'member2', include: [{ model: Member, as: 'member' }] }, + { model: ExternalTournamentParticipant, as: 'external1' }, + { model: ExternalTournamentParticipant, as: 'external2' } + ] + }); + + // Erstelle Items für jede Paarung + itemsToDistribute = pairings.map(pairing => ({ + type: 'pairing', + pairingId: pairing.id, + member1Id: pairing.member1Id, + external1Id: pairing.external1Id, + member2Id: pairing.member2Id, + external2Id: pairing.external2Id + })); + } else { + // Bei Einzel: Verteile einzelne Spieler + itemsToDistribute = classMembers.map(m => ({ + type: 'member', + memberId: m.id, + isExternal: m.isExternal + })); + } + + if (itemsToDistribute.length === 0) continue; + + // Alle Items zufällig mischen + for (let i = itemsToDistribute.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); - [shuffledMembers[i], shuffledMembers[j]] = [shuffledMembers[j], shuffledMembers[i]]; + [itemsToDistribute[i], itemsToDistribute[j]] = [itemsToDistribute[j], itemsToDistribute[i]]; } - // 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 + // Berechne gleichmäßige Verteilung const numGroups = groups.length; - const groupSizes = groupAssignments.map((group, idx) => ({ idx, size: group.length })); - groupSizes.sort((a, b) => b.size - a.size); + const itemsPerGroup = Math.floor(itemsToDistribute.length / numGroups); + const remainder = itemsToDistribute.length % numGroups; - // 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); + // Verteile gleichmäßig + const groupAssignments = groups.map(() => []); + let itemIndex = 0; + + for (let groupIdx = 0; groupIdx < numGroups; groupIdx++) { + const itemsForThisGroup = itemsPerGroup + (groupIdx < remainder ? 1 : 0); + for (let i = 0; i < itemsForThisGroup; i++) { + if (itemIndex < itemsToDistribute.length) { + groupAssignments[groupIdx].push(itemsToDistribute[itemIndex]); + itemIndex++; + } } } - // 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; - } - } + // Bei Einzel-Klassen: Optimiere die Verteilung der gesetzten Spieler + if (!isDoubles) { + // Lade Member-Daten für gesetzte Spieler + const memberIds = itemsToDistribute.filter(item => item.type === 'member').map(item => item.memberId); + const membersData = await TournamentMember.findAll({ + where: { id: { [Op.in]: memberIds }, tournamentId } + }); + const externalIds = itemsToDistribute.filter(item => item.type === 'member' && item.isExternal).map(item => item.memberId); + const externalsData = externalIds.length > 0 ? await ExternalTournamentParticipant.findAll({ + where: { id: { [Op.in]: externalIds }, tournamentId } + }) : []; - let minSeededIdx = 0; - let minSeededCount = seededCounts[0]; - for (let i = 1; i < numGroups; i++) { - if (seededCounts[i] < minSeededCount) { - minSeededCount = seededCounts[i]; - minSeededIdx = i; - } - } + const memberDataMap = new Map(); + membersData.forEach(m => memberDataMap.set(m.id, m)); + externalsData.forEach(e => memberDataMap.set(e.id, e)); - 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; - } - } + const seededCounts = groupAssignments.map(group => + group.filter(item => { + if (item.type !== 'member') return false; + const memberData = memberDataMap.get(item.memberId); + return memberData && memberData.seeded; + }).length + ); - if (!changed) break; + 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(item => { + if (item.type !== 'member') return false; + const memberData = memberDataMap.get(item.memberId); + return memberData && memberData.seeded; + }); + if (seededInMax.length > 0) { + const itemToMove = seededInMax[0]; + groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(item => + !(item.type === 'member' && item.memberId === itemToMove.memberId) + ); + seededCounts[maxSeededIdx]--; + groupAssignments[minSeededIdx].push(itemToMove); + seededCounts[minSeededIdx]++; + changed = true; + } + } + + if (!changed) break; + } } // Speichere Zuordnungen - groupAssignments.forEach((groupMembers, groupIdx) => { - groupMembers.forEach(member => { - if (member.isExternal) { + groupAssignments.forEach((groupItems, groupIdx) => { + groupItems.forEach(item => { + if (item.type === 'pairing') { + // Bei Paarungen: Aktualisiere die Paarung selbst und beide Spieler allUpdatePromises.push( - ExternalTournamentParticipant.update( + TournamentPairing.update( { groupId: groups[groupIdx].id }, - { where: { id: member.id } } + { where: { id: item.pairingId } } ) ); + // Aktualisiere auch beide Spieler der Paarung + if (item.member1Id) { + allUpdatePromises.push( + TournamentMember.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.member1Id } } + ) + ); + } + if (item.external1Id) { + allUpdatePromises.push( + ExternalTournamentParticipant.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.external1Id } } + ) + ); + } + if (item.member2Id) { + allUpdatePromises.push( + TournamentMember.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.member2Id } } + ) + ); + } + if (item.external2Id) { + allUpdatePromises.push( + ExternalTournamentParticipant.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.external2Id } } + ) + ); + } } else { - allUpdatePromises.push( - TournamentMember.update( - { groupId: groups[groupIdx].id }, - { where: { id: member.id } } - ) - ); + // Bei einzelnen Spielern + if (item.isExternal) { + allUpdatePromises.push( + ExternalTournamentParticipant.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.memberId } } + ) + ); + } else { + allUpdatePromises.push( + TournamentMember.update( + { groupId: groups[groupIdx].id }, + { where: { id: item.memberId } } + ) + ); + } } }); }); @@ -374,48 +544,112 @@ class TournamentService { await Promise.all(allUpdatePromises); // 6) Round‑Robin anlegen - NUR innerhalb jeder Gruppe + // Lade alle Klassen, um zu prüfen, ob es sich um Doppel-Klassen handelt + const tournamentClasses = await TournamentClass.findAll({ where: { tournamentId } }); + const classIsDoublesMap = tournamentClasses.reduce((map, cls) => { + map[cls.id] = cls.isDoubles; + return map; + }, {}); + 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 } }); + const classId = g.classId; + const isDoubles = classId ? (classIsDoublesMap[classId] || false) : false; - // 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; - } - - // 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 [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 - }); + if (isDoubles) { + // Bei Doppel: Lade Paarungen für diese Gruppe + const pairings = await TournamentPairing.findAll({ + where: { tournamentId, classId: classId, groupId: g.id }, + include: [ + { model: TournamentMember, as: 'member1', include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'member2', include: [{ model: Member, as: 'member' }] }, + { model: ExternalTournamentParticipant, as: 'external1' }, + { model: ExternalTournamentParticipant, as: 'external2' } + ] + }); + + if (pairings.length < 2) { + continue; + } + + // Erstelle Round-Robin zwischen Paarungen + // Verwende die erste Spieler-ID jeder Paarung als Repräsentant für das Match + const pairingItems = pairings.map(p => ({ + pairingId: p.id, + player1Id: p.member1Id || p.external1Id, + player2Id: p.member2Id || p.external2Id, + key: `pairing-${p.id}` + })); + + const rounds = this.generateRoundRobinSchedule(pairingItems.map(p => ({ id: p.key }))); + + for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) { + for (const [p1Key, p2Key] of rounds[roundIndex]) { + if (p1Key && p2Key) { + const pairing1 = pairingItems.find(p => p.key === p1Key); + const pairing2 = pairingItems.find(p => p.key === p2Key); + + if (pairing1 && pairing2) { + // Erstelle Match zwischen den beiden Paarungen + // Verwende die erste Spieler-ID jeder Paarung + // Die Match-Erkennung in getGroupsWithParticipants prüft, ob player1Id oder player2Id + // mit den Spieler-IDs der Paarungen übereinstimmen + await TournamentMatch.create({ + tournamentId, + groupId: g.id, + round: 'group', + player1Id: pairing1.player1Id, + player2Id: pairing2.player1Id, + groupRound: roundIndex + 1, + classId: g.classId + }); + } + } + } + } + } else { + // Bei Einzel: Normale Logik mit einzelnen Spielern + // 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; + } + + // 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 [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 + }); + } } } } @@ -426,7 +660,7 @@ class TournamentService { // Ähnlich wie getGroupsWithParticipants, aber ohne Gruppierung const internalMembers = await TournamentMember.findAll({ where: { tournamentId }, - include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }] + include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName', 'gender'] }] }); const externalMembers = await ExternalTournamentParticipant.findAll({ @@ -485,7 +719,7 @@ class TournamentService { include: [{ model: TournamentMember, as: 'tournamentGroupMembers', - include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }] + include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName', 'gender'] }] }, { model: ExternalTournamentParticipant, as: 'externalGroupMembers' @@ -515,71 +749,179 @@ class TournamentService { const result = []; for (const [classKey, classGroups] of Object.entries(groupsByClass)) { + // Prüfe, ob diese Klasse ein Doppel ist + const classId = classKey === 'null' ? null : parseInt(classKey); + const tournamentClass = classId ? await TournamentClass.findOne({ + where: { id: classId, tournamentId } + }) : null; + const isDoubles = tournamentClass ? tournamentClass.isDoubles : false; + + // Lade Paarungen für diese Klasse, falls Doppel + let pairingsByGroup = {}; + if (isDoubles && classId) { + const pairings = await TournamentPairing.findAll({ + where: { tournamentId, classId: classId }, + include: [ + { model: TournamentMember, as: 'member1', include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'member2', include: [{ model: Member, as: 'member' }] }, + { model: ExternalTournamentParticipant, as: 'external1' }, + { model: ExternalTournamentParticipant, as: 'external2' } + ] + }); + + // Gruppiere Paarungen nach groupId + pairings.forEach(pairing => { + if (pairing.groupId) { + if (!pairingsByGroup[pairing.groupId]) { + pairingsByGroup[pairing.groupId] = []; + } + pairingsByGroup[pairing.groupId].push(pairing); + } + }); + } + classGroups.forEach((g, idx) => { // Berechne Rankings für diese Gruppe const stats = {}; - // Interne Teilnehmer - for (const tm of g.tournamentGroupMembers || []) { - stats[tm.id] = { - id: tm.id, - name: `${tm.member.firstName} ${tm.member.lastName}`, - seeded: tm.seeded || false, - isExternal: false, - points: 0, - setsWon: 0, - setsLost: 0, - pointsWon: 0, - pointsLost: 0, - pointRatio: 0 - }; - } - - // Externe Teilnehmer - for (const ext of g.externalGroupMembers || []) { - stats[ext.id] = { - id: ext.id, - name: `${ext.firstName} ${ext.lastName}`, - seeded: ext.seeded || false, - isExternal: true, - points: 0, - setsWon: 0, - setsLost: 0, - pointsWon: 0, - pointsLost: 0, - pointRatio: 0 - }; + if (isDoubles && pairingsByGroup[g.id]) { + // Bei Doppel: Verwende Paarungen + for (const pairing of pairingsByGroup[g.id]) { + const player1Name = pairing.member1?.member + ? `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}` + : pairing.external1 + ? `${pairing.external1.firstName} ${pairing.external1.lastName}` + : 'Unbekannt'; + const player2Name = pairing.member2?.member + ? `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}` + : pairing.external2 + ? `${pairing.external2.firstName} ${pairing.external2.lastName}` + : 'Unbekannt'; + + stats[`pairing_${pairing.id}`] = { + id: `pairing_${pairing.id}`, + pairingId: pairing.id, + name: `${player1Name} / ${player2Name}`, + seeded: pairing.seeded || false, + isExternal: false, + isPairing: true, + player1Id: pairing.member1Id || pairing.external1Id, + player2Id: pairing.member2Id || pairing.external2Id, + points: 0, + setsWon: 0, + setsLost: 0, + pointsWon: 0, + pointsLost: 0, + pointRatio: 0 + }; + } + } else { + // Bei Einzel: Verwende einzelne Spieler + // Interne Teilnehmer + for (const tm of g.tournamentGroupMembers || []) { + stats[tm.id] = { + id: tm.id, + name: `${tm.member.firstName} ${tm.member.lastName}`, + seeded: tm.seeded || false, + isExternal: false, + points: 0, + setsWon: 0, + setsLost: 0, + pointsWon: 0, + pointsLost: 0, + pointRatio: 0 + }; + } + + // Externe Teilnehmer + for (const ext of g.externalGroupMembers || []) { + stats[ext.id] = { + id: ext.id, + name: `${ext.firstName} ${ext.lastName}`, + seeded: ext.seeded || false, + isExternal: true, + points: 0, + setsWon: 0, + setsLost: 0, + pointsWon: 0, + pointsLost: 0, + pointRatio: 0 + }; + } } // Berechne Statistiken aus Matches for (const m of groupMatches.filter(m => m.groupId === g.id)) { - if (!stats[m.player1Id] || !stats[m.player2Id]) continue; - const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); - - if (s1 > s2) { - stats[m.player1Id].points += 1; // Sieger bekommt +1 - stats[m.player2Id].points -= 1; // Verlierer bekommt -1 - } else if (s2 > s1) { - stats[m.player2Id].points += 1; // Sieger bekommt +1 - stats[m.player1Id].points -= 1; // Verlierer bekommt -1 - } - - stats[m.player1Id].setsWon += s1; - stats[m.player1Id].setsLost += s2; - stats[m.player2Id].setsWon += s2; - stats[m.player2Id].setsLost += s1; - - // Berechne gespielte Punkte aus tournamentResults - if (m.tournamentResults && m.tournamentResults.length > 0) { - let p1Points = 0, p2Points = 0; - for (const r of m.tournamentResults) { - p1Points += r.pointsPlayer1 || 0; - p2Points += r.pointsPlayer2 || 0; + if (isDoubles) { + // Bei Doppel: Finde die Paarungen für player1Id und player2Id + const pairing1Key = Object.keys(stats).find(key => + stats[key].isPairing && + (stats[key].player1Id === m.player1Id || stats[key].player2Id === m.player1Id) + ); + const pairing2Key = Object.keys(stats).find(key => + stats[key].isPairing && + (stats[key].player1Id === m.player2Id || stats[key].player2Id === m.player2Id) + ); + + if (!pairing1Key || !pairing2Key) continue; + + const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); + + if (s1 > s2) { + stats[pairing1Key].points += 1; + stats[pairing2Key].points -= 1; + } else if (s2 > s1) { + stats[pairing2Key].points += 1; + stats[pairing1Key].points -= 1; + } + + stats[pairing1Key].setsWon += s1; + stats[pairing1Key].setsLost += s2; + stats[pairing2Key].setsWon += s2; + stats[pairing2Key].setsLost += s1; + + // Berechne gespielte Punkte aus tournamentResults + if (m.tournamentResults && m.tournamentResults.length > 0) { + let p1Points = 0, p2Points = 0; + for (const r of m.tournamentResults) { + p1Points += r.pointsPlayer1 || 0; + p2Points += r.pointsPlayer2 || 0; + } + stats[pairing1Key].pointsWon += p1Points; + stats[pairing1Key].pointsLost += p2Points; + stats[pairing2Key].pointsWon += p2Points; + stats[pairing2Key].pointsLost += p1Points; + } + } else { + // Bei Einzel: Normale Logik + if (!stats[m.player1Id] || !stats[m.player2Id]) continue; + const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); + + if (s1 > s2) { + stats[m.player1Id].points += 1; + stats[m.player2Id].points -= 1; + } else if (s2 > s1) { + stats[m.player2Id].points += 1; + stats[m.player1Id].points -= 1; + } + + stats[m.player1Id].setsWon += s1; + stats[m.player1Id].setsLost += s2; + stats[m.player2Id].setsWon += s2; + stats[m.player2Id].setsLost += s1; + + // Berechne gespielte Punkte aus tournamentResults + if (m.tournamentResults && m.tournamentResults.length > 0) { + let p1Points = 0, p2Points = 0; + for (const r of m.tournamentResults) { + p1Points += r.pointsPlayer1 || 0; + p2Points += r.pointsPlayer2 || 0; + } + stats[m.player1Id].pointsWon += p1Points; + stats[m.player1Id].pointsLost += p2Points; + stats[m.player2Id].pointsWon += p2Points; + stats[m.player2Id].pointsLost += p1Points; } - stats[m.player1Id].pointsWon += p1Points; - stats[m.player1Id].pointsLost += p2Points; - stats[m.player2Id].pointsWon += p2Points; - stats[m.player2Id].pointsLost += p1Points; } } @@ -604,17 +946,36 @@ class TournamentService { // 5. Bei Spielpunktgleichheit: Wer mehr Spielpunkte erzielt hat if (b.pointsWon !== a.pointsWon) return b.pointsWon - a.pointsWon; // 6. Direkter Vergleich (Sieger weiter oben) - const directMatch = groupMatches.find(m => - m.groupId === g.id && - ((m.player1Id === a.id && m.player2Id === b.id) || - (m.player1Id === b.id && m.player2Id === a.id)) - ); - if (directMatch) { - const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); - const aWon = (directMatch.player1Id === a.id && s1 > s2) || - (directMatch.player2Id === a.id && s2 > s1); - if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben - return 1; // b hat gewonnen -> b kommt weiter oben + let directMatch; + if (isDoubles && a.isPairing && b.isPairing) { + // Bei Doppel: Finde Match zwischen den Paarungen + directMatch = groupMatches.find(m => { + const aPlayer1 = a.player1Id === m.player1Id || a.player2Id === m.player1Id; + const aPlayer2 = a.player1Id === m.player2Id || a.player2Id === m.player2Id; + const bPlayer1 = b.player1Id === m.player1Id || b.player2Id === m.player1Id; + const bPlayer2 = b.player1Id === m.player2Id || b.player2Id === m.player2Id; + return m.groupId === g.id && ((aPlayer1 && bPlayer2) || (aPlayer2 && bPlayer1)); + }); + if (directMatch) { + const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); + const aPlayer1 = a.player1Id === directMatch.player1Id || a.player2Id === directMatch.player1Id; + const aWon = aPlayer1 ? (s1 > s2) : (s2 > s1); + if (aWon) return -1; + return 1; + } + } else if (!isDoubles) { + directMatch = groupMatches.find(m => + m.groupId === g.id && + ((m.player1Id === a.id && m.player2Id === b.id) || + (m.player1Id === b.id && m.player2Id === a.id)) + ); + if (directMatch) { + const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); + const aWon = (directMatch.player1Id === a.id && s1 > s2) || + (directMatch.player2Id === a.id && s2 > s1); + if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben + return 1; // b hat gewonnen -> b kommt weiter oben + } } // Fallback: Alphabetisch nach Name return a.name.localeCompare(b.name); @@ -635,11 +996,23 @@ class TournamentService { if (pointsEqual && setDiffEqual && setsWonEqual && pointsDiffEqual && pointsWonEqual) { // Prüfe direkten Vergleich - const directMatch = groupMatches.find(m => - m.groupId === g.id && - ((m.player1Id === prev.id && m.player2Id === p.id) || - (m.player1Id === p.id && m.player2Id === prev.id)) - ); + let directMatch; + if (isDoubles && prev.isPairing && p.isPairing) { + // Bei Doppel: Finde Match zwischen den Paarungen + directMatch = groupMatches.find(m => { + const prevPlayer1 = prev.player1Id === m.player1Id || prev.player2Id === m.player1Id; + const prevPlayer2 = prev.player1Id === m.player2Id || prev.player2Id === m.player2Id; + const pPlayer1 = p.player1Id === m.player1Id || p.player2Id === m.player1Id; + const pPlayer2 = p.player1Id === m.player2Id || p.player2Id === m.player2Id; + return m.groupId === g.id && ((prevPlayer1 && pPlayer2) || (prevPlayer2 && pPlayer1)); + }); + } else if (!isDoubles) { + directMatch = groupMatches.find(m => + m.groupId === g.id && + ((m.player1Id === prev.id && m.player2Id === p.id) || + (m.player1Id === p.id && m.player2Id === prev.id)) + ); + } if (!directMatch || directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) { // Gleicher Platz wie Vorgänger (unentschieden oder kein direktes Match) return { @@ -893,8 +1266,17 @@ class TournamentService { include: [{ model: TournamentResult, as: "tournamentResults" }] }); + // Lade alle Klassen, um zu prüfen, ob es sich um Doppel-Klassen handelt + const tournamentClasses = await TournamentClass.findAll({ where: { tournamentId } }); + const classIsDoublesMap = tournamentClasses.reduce((map, cls) => { + map[cls.id] = cls.isDoubles; + return map; + }, {}); + const qualifiers = []; for (const g of groups) { + const classId = g.classId; + const isDoubles = classId ? (classIsDoublesMap[classId] || false) : false; const stats = {}; // Interne Teilnehmer for (const tm of g.tournamentGroupMembers || []) { @@ -969,9 +1351,9 @@ class TournamentService { // Fallback: Nach ID return a.member.id - b.member.id; }); - // Füge classId zur Gruppe hinzu + // Füge classId und groupId zur Gruppe hinzu // r.member ist entweder TournamentMember oder ExternalTournamentParticipant - qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map(r => { + qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map((r, position) => { const member = r.member; // Stelle sicher, dass id vorhanden ist if (!member || !member.id) { @@ -981,7 +1363,9 @@ class TournamentService { return { id: member.id, classId: g.classId, - isExternal: r.isExternal || false + groupId: g.id, + isExternal: r.isExternal || false, + position: position + 1 // 1-basierte Position innerhalb der Gruppe (1., 2., 3., etc.) }; }).filter(q => q !== null)); } @@ -1036,15 +1420,91 @@ class TournamentService { 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); + // Gruppiere Qualifiers nach Gruppen + const qualifiersByGroup = {}; + classQualifiers.forEach(q => { + const groupKey = q.groupId || 'null'; + if (!qualifiersByGroup[groupKey]) { + qualifiersByGroup[groupKey] = []; + } + qualifiersByGroup[groupKey].push(q); + }); - for (let i = 0; i < numMatches; i++) { - const player1 = classQualifiers[i]; - const player2 = classQualifiers[roundSize - 1 - i]; + // Sortiere Qualifiers innerhalb jeder Gruppe nach Position (1., 2., 3., etc.) + Object.keys(qualifiersByGroup).forEach(groupKey => { + qualifiersByGroup[groupKey].sort((a, b) => a.position - b.position); + }); + + // Erstelle eine flache Liste aller Qualifiers, gruppiert nach Gruppen + const groups = Object.keys(qualifiersByGroup); + const advancingPerGroup = t.advancingPerGroup; + + // Erstelle Paarungen: 1. gegen letzter weitergekommener, 2. gegen vorletzter, etc. + // Wichtig: Niemand darf gegen jemanden aus der eigenen Gruppe spielen + const matches = []; + const usedQualifiers = new Set(); + + // Für jede Position (1., 2., 3., etc.) + for (let pos = 1; pos <= advancingPerGroup; pos++) { + // Finde alle Qualifiers mit dieser Position + const qualifiersAtPosition = []; + groups.forEach(groupKey => { + const groupQualifiers = qualifiersByGroup[groupKey]; + const qualifierAtPos = groupQualifiers.find(q => q.position === pos); + if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) { + qualifiersAtPosition.push(qualifierAtPos); + } + }); - if (!player1 || !player2 || !player1.id || !player2.id) { - devLog(`[startKnockout] Warning: Invalid qualifier at index ${i} or ${roundSize - 1 - i} for class ${classKey}`); + // Paare jeden Qualifier dieser Position mit dem entsprechenden Gegner + // 1. Platz spielt gegen letzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 1. gegen 2.) + // 2. Platz spielt gegen vorletzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 2. gegen 1.) + const opponentPosition = advancingPerGroup - pos + 1; + + qualifiersAtPosition.forEach(qualifier => { + // Finde Gegner mit opponentPosition aus einer anderen Gruppe + let opponent = null; + for (const groupKey of groups) { + if (groupKey === qualifier.groupId.toString()) continue; // Nicht aus derselben Gruppe + + const groupQualifiers = qualifiersByGroup[groupKey]; + const opponentCandidate = groupQualifiers.find(q => + q.position === opponentPosition && !usedQualifiers.has(q.id) + ); + + if (opponentCandidate) { + opponent = opponentCandidate; + break; + } + } + + // Falls kein Gegner gefunden, suche nach einem beliebigen Gegner aus einer anderen Gruppe + if (!opponent) { + for (const groupKey of groups) { + if (groupKey === qualifier.groupId.toString()) continue; + + const groupQualifiers = qualifiersByGroup[groupKey]; + const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id)); + + if (opponentCandidate) { + opponent = opponentCandidate; + break; + } + } + } + + if (opponent) { + matches.push({ player1: qualifier, player2: opponent }); + usedQualifiers.add(qualifier.id); + usedQualifiers.add(opponent.id); + } + }); + } + + // Erstelle die Matches in der Datenbank + for (const match of matches) { + if (!match.player1 || !match.player2 || !match.player1.id || !match.player2.id) { + devLog(`[startKnockout] Warning: Invalid match pair for class ${classKey}`); continue; } @@ -1052,8 +1512,8 @@ class TournamentService { await TournamentMatch.create({ tournamentId, round: rn, - player1Id: player1.id, - player2Id: player2.id, + player1Id: match.player1.id, + player2Id: match.player2.id, classId: classId }); } catch (error) { @@ -1079,7 +1539,7 @@ class TournamentService { throw new Error('Turnier nicht gefunden'); } const totalMembers = assignments.length; - if (totalMembers === 0) { + if (totalMembers === 0) { throw new Error('Keine Teilnehmer zum Verteilen'); } @@ -1282,6 +1742,27 @@ class TournamentService { async removeParticipant(userToken, clubId, tournamentId, participantId) { await checkAccess(userToken, clubId); + + // Prüfe, ob der Teilnehmer existiert + const participant = await TournamentMember.findOne({ + where: { id: participantId, tournamentId } + }); + if (!participant) { + throw new Error('Teilnehmer nicht gefunden'); + } + + // Lösche alle Matches, die auf diesen Teilnehmer verweisen (player1_id oder player2_id) + await TournamentMatch.destroy({ + where: { + tournamentId, + [Op.or]: [ + { player1Id: participantId }, + { player2Id: participantId } + ] + } + }); + + // Jetzt kann der Teilnehmer gelöscht werden await TournamentMember.destroy({ where: { id: participantId, tournamentId } }); @@ -1368,34 +1849,86 @@ class TournamentService { } // Externe Teilnehmer hinzufügen - async addExternalParticipant(userToken, clubId, tournamentId, firstName, lastName, club, birthDate) { + async addExternalParticipant(userToken, clubId, classId, firstName, lastName, club, birthDate, gender) { await checkAccess(userToken, clubId); - const tournament = await Tournament.findByPk(tournamentId); + if (!classId) { + throw new Error('Klasse ist erforderlich'); + } + const tournamentClass = await TournamentClass.findByPk(classId); + if (!tournamentClass) { + throw new Error('Klasse nicht gefunden'); + } + const tournament = await Tournament.findByPk(tournamentClass.tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } if (!tournament.allowsExternal) { throw new Error('Dieses Turnier erlaubt keine externen Teilnehmer'); } + + // Validierung: Geschlecht muss zur Klasse passen + const participantGender = gender || 'unknown'; + if (tournamentClass.gender) { + if (tournamentClass.gender === 'male' && participantGender !== 'male') { + throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); + } + if (tournamentClass.gender === 'female' && participantGender !== 'female') { + throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); + } + if (tournamentClass.gender === 'mixed' && participantGender === 'unknown') { + throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); + } + // mixed erlaubt alle Geschlechter (male, female, diverse) + } + + // Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=) + if (tournamentClass.minBirthYear && birthDate) { + // Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY) + let birthYear = null; + if (birthDate.includes('-')) { + // Format: YYYY-MM-DD + birthYear = parseInt(birthDate.split('-')[0]); + } else if (birthDate.includes('.')) { + // Format: DD.MM.YYYY + const parts = birthDate.split('.'); + if (parts.length === 3) { + birthYear = parseInt(parts[2]); + } + } + + if (birthYear && !isNaN(birthYear)) { + // Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear + if (birthYear < tournamentClass.minBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); + } + } + } + await ExternalTournamentParticipant.create({ - tournamentId, + tournamentId: tournamentClass.tournamentId, + classId, firstName, lastName, club: club || null, birthDate: birthDate || null, + gender: participantGender, groupId: null }); } - // Externe Teilnehmer abrufen - async getExternalParticipants(userToken, clubId, tournamentId) { + // Externe Teilnehmer abrufen - nach Klasse oder Turnier + async getExternalParticipants(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } + const whereClause = { tournamentId }; + if (classId !== null) { + whereClause.classId = classId; + } return await ExternalTournamentParticipant.findAll({ - where: { tournamentId }, + where: whereClause, order: [['firstName', 'ASC'], ['lastName', 'ASC']] }); } @@ -1442,7 +1975,7 @@ class TournamentService { }); } - async addTournamentClass(userToken, clubId, tournamentId, name) { + async addTournamentClass(userToken, clubId, tournamentId, name, isDoubles = false, gender = null, minBirthYear = null) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { @@ -1455,11 +1988,14 @@ class TournamentService { return await TournamentClass.create({ tournamentId, name, - sortOrder: maxSortOrder + 1 + sortOrder: maxSortOrder + 1, + isDoubles: isDoubles || false, + gender: gender || null, + minBirthYear: minBirthYear || null }); } - async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder) { + async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { @@ -1471,9 +2007,37 @@ class TournamentService { if (!tournamentClass) { throw new Error('Klasse nicht gefunden'); } - if (name !== undefined) tournamentClass.name = name; - if (sortOrder !== undefined) tournamentClass.sortOrder = sortOrder; - await tournamentClass.save(); + console.log('[updateTournamentClass] Before update:', { + id: tournamentClass.id, + name: tournamentClass.name, + isDoubles: tournamentClass.isDoubles, + gender: tournamentClass.gender, + minBirthYear: tournamentClass.minBirthYear + }); + console.log('[updateTournamentClass] New values:', { name, sortOrder, isDoubles, gender, minBirthYear }); + + // Verwende update() statt direkter Zuweisung für bessere Kontrolle + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + if (isDoubles !== undefined) updateData.isDoubles = isDoubles; + if (gender !== undefined) updateData.gender = gender; + if (minBirthYear !== undefined) updateData.minBirthYear = minBirthYear; + + console.log('[updateTournamentClass] Update data:', updateData); + + await tournamentClass.update(updateData); + + // Lade die aktualisierte Instanz neu, um sicherzustellen, dass wir die aktuellen DB-Werte haben + await tournamentClass.reload(); + + console.log('[updateTournamentClass] After update and reload:', { + id: tournamentClass.id, + name: tournamentClass.name, + isDoubles: tournamentClass.isDoubles, + gender: tournamentClass.gender, + minBirthYear: tournamentClass.minBirthYear + }); return tournamentClass; } @@ -1536,6 +2100,194 @@ class TournamentService { } } + // Tournament Pairings + async getPairings(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'); + } + if (!tournamentClass.isDoubles) { + throw new Error('Klasse ist keine Doppel-Klasse'); + } + return await TournamentPairing.findAll({ + where: { tournamentId, classId }, + include: [ + { model: TournamentMember, as: 'member1', include: [{ model: Member, as: 'member' }] }, + { model: TournamentMember, as: 'member2', include: [{ model: Member, as: 'member' }] }, + { model: ExternalTournamentParticipant, as: 'external1' }, + { model: ExternalTournamentParticipant, as: 'external2' } + ], + order: [['id', 'ASC']] + }); + } + + async createPairing(userToken, clubId, tournamentId, classId, player1Type, player1Id, player2Type, player2Id, seeded = false, groupId = null) { + 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 (!tournamentClass.isDoubles) { + throw new Error('Klasse ist keine Doppel-Klasse'); + } + // Validiere, dass beide Spieler existieren + if (player1Type === 'member') { + const member1 = await TournamentMember.findOne({ + where: { id: player1Id, tournamentId } + }); + if (!member1) { + throw new Error('Spieler 1 (Mitglied) nicht gefunden'); + } + } else if (player1Type === 'external') { + const external1 = await ExternalTournamentParticipant.findOne({ + where: { id: player1Id, tournamentId } + }); + if (!external1) { + throw new Error('Spieler 1 (extern) nicht gefunden'); + } + } + if (player2Type === 'member') { + const member2 = await TournamentMember.findOne({ + where: { id: player2Id, tournamentId } + }); + if (!member2) { + throw new Error('Spieler 2 (Mitglied) nicht gefunden'); + } + } else if (player2Type === 'external') { + const external2 = await ExternalTournamentParticipant.findOne({ + where: { id: player2Id, tournamentId } + }); + if (!external2) { + throw new Error('Spieler 2 (extern) nicht gefunden'); + } + } + // Prüfe auf Duplikate: gleiche Paarung (in beiden Richtungen) + // Baue die Bedingungen für beide Richtungen + const condition1 = { + tournamentId, + classId, + member1Id: player1Type === 'member' ? player1Id : null, + external1Id: player1Type === 'external' ? player1Id : null, + member2Id: player2Type === 'member' ? player2Id : null, + external2Id: player2Type === 'external' ? player2Id : null + }; + const condition2 = { + tournamentId, + classId, + member1Id: player2Type === 'member' ? player2Id : null, + external1Id: player2Type === 'external' ? player2Id : null, + member2Id: player1Type === 'member' ? player1Id : null, + external2Id: player1Type === 'external' ? player1Id : null + }; + + // Prüfe beide Bedingungen + const existingPairing1 = await TournamentPairing.findOne({ where: condition1 }); + const existingPairing2 = await TournamentPairing.findOne({ where: condition2 }); + const existingPairing = existingPairing1 || existingPairing2; + if (existingPairing) { + throw new Error('Diese Paarung existiert bereits'); + } + return await TournamentPairing.create({ + tournamentId, + classId, + groupId, + member1Id: player1Type === 'member' ? player1Id : null, + external1Id: player1Type === 'external' ? player1Id : null, + member2Id: player2Type === 'member' ? player2Id : null, + external2Id: player2Type === 'external' ? player2Id : null, + seeded + }); + } + + async updatePairing(userToken, clubId, tournamentId, pairingId, player1Type, player1Id, player2Type, player2Id, seeded, groupId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + const pairing = await TournamentPairing.findOne({ + where: { id: pairingId, tournamentId } + }); + if (!pairing) { + throw new Error('Paarung nicht gefunden'); + } + // Validiere Spieler, wenn sie geändert werden + if (player1Type !== undefined && player1Id !== undefined) { + if (player1Type === 'member') { + const member1 = await TournamentMember.findOne({ + where: { id: player1Id, tournamentId } + }); + if (!member1) { + throw new Error('Spieler 1 (Mitglied) nicht gefunden'); + } + pairing.member1Id = player1Id; + pairing.external1Id = null; + } else if (player1Type === 'external') { + const external1 = await ExternalTournamentParticipant.findOne({ + where: { id: player1Id, tournamentId } + }); + if (!external1) { + throw new Error('Spieler 1 (extern) nicht gefunden'); + } + pairing.external1Id = player1Id; + pairing.member1Id = null; + } + } + if (player2Type !== undefined && player2Id !== undefined) { + if (player2Type === 'member') { + const member2 = await TournamentMember.findOne({ + where: { id: player2Id, tournamentId } + }); + if (!member2) { + throw new Error('Spieler 2 (Mitglied) nicht gefunden'); + } + pairing.member2Id = player2Id; + pairing.external2Id = null; + } else if (player2Type === 'external') { + const external2 = await ExternalTournamentParticipant.findOne({ + where: { id: player2Id, tournamentId } + }); + if (!external2) { + throw new Error('Spieler 2 (extern) nicht gefunden'); + } + pairing.external2Id = player2Id; + pairing.member2Id = null; + } + } + if (seeded !== undefined) pairing.seeded = seeded; + if (groupId !== undefined) pairing.groupId = groupId; + await pairing.save(); + return pairing; + } + + async deletePairing(userToken, clubId, tournamentId, pairingId) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + const pairing = await TournamentPairing.findOne({ + where: { id: pairingId, tournamentId } + }); + if (!pairing) { + throw new Error('Paarung nicht gefunden'); + } + await pairing.destroy(); + } + } export default new TournamentService(); diff --git a/frontend/src/components/tournament/TournamentClassList.vue b/frontend/src/components/tournament/TournamentClassList.vue new file mode 100644 index 0000000..cdb7d7f --- /dev/null +++ b/frontend/src/components/tournament/TournamentClassList.vue @@ -0,0 +1,377 @@ + + + + + + diff --git a/frontend/src/components/tournament/TournamentClassSelector.vue b/frontend/src/components/tournament/TournamentClassSelector.vue new file mode 100644 index 0000000..9263c5e --- /dev/null +++ b/frontend/src/components/tournament/TournamentClassSelector.vue @@ -0,0 +1,38 @@ + + + + diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue new file mode 100644 index 0000000..6f813c2 --- /dev/null +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -0,0 +1,147 @@ + + + + diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue new file mode 100644 index 0000000..e5cbf0c --- /dev/null +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -0,0 +1,349 @@ + + + + diff --git a/frontend/src/components/tournament/TournamentParticipantsTab.vue b/frontend/src/components/tournament/TournamentParticipantsTab.vue new file mode 100644 index 0000000..a757dbb --- /dev/null +++ b/frontend/src/components/tournament/TournamentParticipantsTab.vue @@ -0,0 +1,630 @@ + + + + + diff --git a/frontend/src/components/tournament/TournamentResultsTab.vue b/frontend/src/components/tournament/TournamentResultsTab.vue new file mode 100644 index 0000000..bc0f533 --- /dev/null +++ b/frontend/src/components/tournament/TournamentResultsTab.vue @@ -0,0 +1,413 @@ + + + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0549567..18ce619 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -578,6 +578,17 @@ "delete": "Löschen", "className": "Klassenname", "addClass": "Klasse hinzufügen", + "noClassesYet": "Noch keine Klassen vorhanden. Fügen Sie eine neue Klasse hinzu.", + "singles": "Einzel", + "doubles": "Doppel", + "genderAll": "Alle", + "genderMixed": "Mixed", + "minBirthYear": "Geboren im Jahr oder später", + "selectClass": "Klasse auswählen", + "tabConfig": "Konfiguration", + "tabGroups": "Gruppen", + "tabParticipants": "Teilnehmer", + "tabResults": "Ergebnisse", "participants": "Teilnehmer", "seeded": "Gesetzt", "club": "Verein", @@ -597,7 +608,13 @@ "addClubMember": "Vereinsmitglied hinzufügen", "advancersPerGroup": "Aufsteiger pro Gruppe", "maxGroupSize": "Maximale Gruppengröße", - "groupsPerClass": "Gruppen pro Klasse", + "groupsPerClass": "Gruppen", + "groupsPerClassHint": "Geben Sie für jede Klasse die Anzahl der Gruppen ein (0 = keine Gruppen für diese Klasse):", + "showClass": "Klasse anzeigen", + "allClasses": "Alle Klassen", + "withoutClass": "Ohne Klasse", + "currentClass": "Aktive Klasse", + "selectClassPrompt": "Bitte wählen Sie oben eine Klasse aus.", "numberOfGroups": "Anzahl Gruppen", "createGroups": "Gruppen erstellen", "randomizeGroups": "Zufällig verteilen", @@ -612,6 +629,13 @@ "diff": "Diff", "pointsRatio": "Spielpunkte", "livePosition": "Live-Platz", + "pairings": "Doppel-Paarungen", + "addPairing": "Paarung hinzufügen", + "selectPlayer": "Spieler auswählen", + "external": "Extern", + "randomPairings": "Zufällige Doppel-Paarungen", + "errorMoreSeededThanUnseeded": "Es gibt mehr gesetzte als nicht gesetzte Spieler. Zufällige Paarungen können nicht erstellt werden.", + "randomPairingsCreated": "Zufällige Paarungen wurden erstellt.", "resetGroupMatches": "Gruppenspiele", "groupMatches": "Gruppenspiele", "round": "Runde", @@ -1166,6 +1190,7 @@ "title": "Trainings-Details", "birthdate": "Geburtsdatum", "birthYear": "Geburtsjahr", + "maxBirthYear": "Geboren ≤ Jahr", "last12Months": "Letzte 12 Monate", "last3Months": "Letzte 3 Monate", "total": "Gesamt", diff --git a/frontend/src/services/socketService.js b/frontend/src/services/socketService.js index e3894e3..85c9517 100644 --- a/frontend/src/services/socketService.js +++ b/frontend/src/services/socketService.js @@ -17,20 +17,27 @@ export const connectSocket = (clubId) => { // Produktion: HTTPS direkt auf Port 3051 socketUrl = 'https://tt-tagebuch.de:3051'; } else { - // Entwicklung: Verwende backendBaseUrl + // Entwicklung: Socket.IO läuft auf demselben Port wie der HTTP-Server (3005) + // Oder auf HTTPS-Port 3051, falls SSL-Zertifikate vorhanden sind + // Versuche zuerst HTTP, dann HTTPS socketUrl = backendBaseUrl; + // Falls der Server auf HTTPS-Port 3051 läuft, verwende diesen + // (wird automatisch auf HTTP zurückfallen, wenn HTTPS nicht verfügbar ist) } + // Bestimme, ob wir HTTPS verwenden + const isHttps = socketUrl.startsWith('https://'); + socket = io(socketUrl, { path: '/socket.io/', - transports: ['websocket', 'polling'], // WebSocket zuerst, dann Fallback zu Polling + transports: ['polling', 'websocket'], // Polling zuerst für bessere Kompatibilität, dann WebSocket reconnection: true, reconnectionDelay: 1000, reconnectionAttempts: 5, timeout: 20000, upgrade: true, forceNew: false, - secure: true, // Wichtig für HTTPS + secure: isHttps, // Nur für HTTPS rejectUnauthorized: false // Für selbst-signierte Zertifikate (nur Entwicklung) }); diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index 2290b28..2db7fa5 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -38,771 +38,174 @@
-
- - - - + +
+ + + +
- -
-
-

{{ $t('tournaments.classes') }}

- -
-
-
-
- - -
-
-
- - -
-
-
-
-
-

{{ $t('tournaments.participants') }}

- -
-
-
-
-
{{ $t('tournaments.addClubMember') }}
-
- - - -
-
-
-
{{ $t('tournaments.addExternalParticipant') }}
-
- - - - - -
-
-
- - - -
-
-
- - - - - - - - - - - -
{{ $t('tournaments.seeded') }}{{ $t('tournaments.name') }}{{ $t('tournaments.club') }}{{ $t('tournaments.class') }}{{ $t('tournaments.group') }}{{ $t('tournaments.action') }}
-
- - - - - - - - - - - -
- - - - - - - - - - - - - -
-
-
-
-
-
- - - -
-

{{ $t('tournaments.groupsPerClass') }}

-
- -
-
-
- -
- - - -
-
-

{{ $t('tournaments.groupsOverview') }}

- -
- -
-
+ + + + + + + + + + +
-
-

{{ $t('tournaments.groupMatches') }}

- - - - - - - - - - - - - - - - - - - - - - -
{{ $t('tournaments.round') }}{{ $t('tournaments.group') }}{{ $t('tournaments.encounter') }}{{ $t('tournaments.result') }}{{ $t('tournaments.sets') }}{{ $t('tournaments.action') }}
{{ m.groupRound }} - - - - - - - - - - - - - {{ getSetsString(m) }} - - - - - - - - -
-
-
- -
-
- -
- -
- -
-
-

{{ $t('tournaments.koRound') }}

- - - - - - - - - - - - - - - - - - - - - -
{{ $t('tournaments.class') }}{{ $t('tournaments.round') }}{{ $t('tournaments.encounter') }}{{ $t('tournaments.result') }}{{ $t('tournaments.sets') }}{{ $t('tournaments.action') }}
{{ getKnockoutMatchClassName(m) }}{{ m.round }} - - - - - - - - - - {{ getSetsString(m) }} - - - - - - -
-
-
-

Rangliste

- -
- ({ ...p, isExternal: false })), - ...this.externalParticipants.map(p => ({ ...p, isExternal: true })) - ] - : this.participants.map(p => ({ ...p, isExternal: false })); - - if (allParticipants.length === 0) { - return []; - } - - return allParticipants.sort((a, b) => { - // Für interne Teilnehmer: verwende member.firstName/lastName - // Für externe Teilnehmer: verwende firstName/lastName direkt - const firstNameA = (a.member?.firstName || a.firstName || '').toLowerCase(); - const firstNameB = (b.member?.firstName || b.firstName || '').toLowerCase(); - if (firstNameA !== firstNameB) { - return firstNameA.localeCompare(firstNameB, 'de'); - } - const lastNameA = (a.member?.lastName || a.lastName || '').toLowerCase(); - const lastNameB = (b.member?.lastName || b.lastName || '').toLowerCase(); - return lastNameA.localeCompare(lastNameB, 'de'); - }); - }, - knockoutMatches() { const koMatches = this.matches.filter(m => m.round !== 'group'); // Sortiere nach Klasse, dann nach Runde @@ -1077,6 +477,55 @@ export default { return map; }, + groupsPerClassInput: { + get() { + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { + return 0; + } + if (this.selectedViewClass === '__none__') { + return this.groupsPerClass['null'] || 0; + } + const classId = Number(this.selectedViewClass); + return this.groupsPerClass[classId] || 0; + }, + set(value) { + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { + return; + } + if (this.selectedViewClass === '__none__') { + this.groupsPerClass['null'] = value; + } else { + const classId = Number(this.selectedViewClass); + this.groupsPerClass[classId] = value; + } + } + }, + + activeAssignmentClassId() { + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { + return undefined; + } + if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') { + return null; + } + const id = Number(this.selectedViewClass); + return Number.isNaN(id) ? undefined : id; + }, + + activeAssignmentClassLabel() { + if (this.activeAssignmentClassId === undefined) { + return this.$t('tournaments.selectClassPrompt'); + } + if (this.activeAssignmentClassId === null) { + return this.$t('tournaments.withoutClass'); + } + return this.getClassName(this.activeAssignmentClassId) || this.$t('tournaments.unknown'); + }, + + canAssignClass() { + return this.activeAssignmentClassId !== undefined; + }, + rankingList() { const finalMatch = this.knockoutMatches.find( m => m.round.toLowerCase() === 'finale' @@ -1281,7 +730,11 @@ export default { getTotalNumberOfGroups() { if (!this.isGroupTournament) return 0; - // Wenn es Klassen gibt, summiere die Gruppen pro Klasse + // Verwende die tatsächliche Anzahl der erstellten Gruppen + if (this.groups && this.groups.length > 0) { + return this.groups.length; + } + // Fallback: Wenn es Klassen gibt, summiere die Gruppen pro Klasse if (this.tournamentClasses && this.tournamentClasses.length > 0) { return Object.values(this.groupsPerClass).reduce((sum, count) => sum + (count || 0), 0); } @@ -1305,10 +758,24 @@ export default { this.groups = []; this.showKnockout = false; this.showParticipants = true; + this.selectedViewClass = '__none__'; + this.activeTab = 'config'; // Setze Tab zurück auf Konfiguration return; } + this.selectedViewClass = null; + this.activeTab = 'config'; // Setze Tab zurück auf Konfiguration beim Laden await this.loadTournamentData(); } + }, + selectedViewClass: { + handler: async function (newVal) { + if (newVal !== null && newVal !== undefined && newVal !== '__none__' && this.isClassDoubles(newVal)) { + await this.loadPairings(); + } else { + this.pairings = []; + } + }, + immediate: false } }, async created() { @@ -1372,8 +839,87 @@ export default { disconnectSocket(); }, methods: { + setActiveTab(tab) { + this.activeTab = tab; + }, + // Hilfsmethode: Hole alle Teilnehmer (intern + extern) - ohne Duplikate + allParticipantsList() { + const all = this.allowsExternal + ? [ + ...this.participants.map(p => ({ ...p, isExternal: false })), + ...this.externalParticipants.map(p => ({ ...p, isExternal: true })) + ] + : this.participants.map(p => ({ ...p, isExternal: false })); + + // Entferne Duplikate basierend auf ID + const seen = new Set(); + return all.filter(p => { + const key = p.id || `${p.clubMemberId || p.externalId || ''}_${p.classId || 'null'}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, + + // Hole Teilnehmer für eine bestimmte Klasse + getParticipantsForClass(classId) { + return this.allParticipantsList().filter(p => { + if (classId === null || classId === '__none__') { + return p.classId === null || p.classId === undefined; + } + // Konvertiere beide Werte zu Zahlen für den Vergleich + const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId; + const pClassIdNum = typeof p.classId === 'string' ? parseInt(p.classId) : p.classId; + return pClassIdNum === classIdNum; + }).sort((a, b) => { + // Für interne Teilnehmer: verwende member.firstName/lastName + // Für externe Teilnehmer: verwende firstName/lastName direkt + const firstNameA = (a.member?.firstName || a.firstName || '').toLowerCase(); + const firstNameB = (b.member?.firstName || b.firstName || '').toLowerCase(); + if (firstNameA !== firstNameB) { + return firstNameA.localeCompare(firstNameB, 'de'); + } + const lastNameA = (a.member?.lastName || a.lastName || '').toLowerCase(); + const lastNameB = (b.member?.lastName || b.lastName || '').toLowerCase(); + return lastNameA.localeCompare(lastNameB, 'de'); + }); + }, + + // Hole Gruppen für eine bestimmte Klasse + getGroupsForClass(classId) { + return this.groups.filter(g => { + if (classId === null) { + return g.classId === null || g.classId === undefined; + } + return g.classId === classId; + }); + }, + + // Prüfe, ob eine Klasse angezeigt werden soll + shouldShowClass(classId) { + // Wenn keine Klasse ausgewählt ist (null), zeige alle + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { + return true; + } + // Wenn "Ohne Klasse" ausgewählt ist + if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') { + return classId === null; + } + // Vergleiche als Zahlen, um String/Number-Probleme zu vermeiden + const selectedId = Number(this.selectedViewClass); + const compareId = Number(classId); + // Prüfe auf NaN (falls Parsing fehlschlägt) + if (Number.isNaN(selectedId) || Number.isNaN(compareId)) { + return false; + } + return selectedId === compareId; + }, getClassName(classId) { - if (!classId || classId === 'null' || classId === 'undefined') return ''; + if (classId === null || classId === '__none__' || classId === 'null' || classId === 'undefined' || classId === undefined) { + return this.$t('tournaments.withoutClass'); + } try { const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId; const classItem = this.tournamentClasses.find(c => c.id === classIdNum); @@ -1401,6 +947,15 @@ export default { return ''; }, + isClassDoubles(classId) { + if (classId === null || classId === '__none__' || classId === 'null' || classId === undefined) { + return false; + } + const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId; + const classItem = this.tournamentClasses.find(c => c.id === classIdNum); + return classItem ? Boolean(classItem.isDoubles) : false; + }, + // Dialog Helper Methods async showInfo(title, message, details = '', type = 'info') { this.infoDialog = buildInfoConfig({ title, message, details, type }); @@ -1474,10 +1029,44 @@ export default { await this.checkTrainingForDate(tournament.date); // Lade Klassen await this.loadTournamentClasses(); - const pRes = await apiClient.post('/tournament/participants', { - clubId: this.currentClub, - tournamentId: this.selectedDate - }); + // Lade Paarungen für alle Doppel-Klassen + await this.loadPairings(); + // Lade Teilnehmer für alle Klassen des Turniers + // Da Teilnehmer jetzt klassengebunden sind, müssen wir sie pro Klasse laden + const allParticipants = []; + for (const classItem of this.tournamentClasses) { + try { + const pRes = await apiClient.post('/tournament/participants', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + classId: classItem.id + }); + // Stelle sicher, dass keine Duplikate hinzugefügt werden + pRes.data.forEach(p => { + if (!allParticipants.find(existing => existing.id === p.id)) { + allParticipants.push(p); + } + }); + } catch (error) { + console.error(`Fehler beim Laden der Teilnehmer für Klasse ${classItem.id}:`, error); + } + } + // Lade auch Teilnehmer ohne Klasse (falls vorhanden) + try { + const pRes = await apiClient.post('/tournament/participants', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + classId: null + }); + pRes.data.forEach(p => { + if (!allParticipants.find(existing => existing.id === p.id)) { + allParticipants.push(p); + } + }); + } catch (error) { + console.error('Fehler beim Laden der Teilnehmer ohne Klasse:', error); + } + const pRes = { data: allParticipants }; // Lade Gruppen zuerst, damit wir groupNumber initialisieren können const gRes = await apiClient.get('/tournament/groups', { params: { @@ -1502,14 +1091,43 @@ export default { groupNumber: p.groupId ? (groupIdToNumberMap[p.groupId] || null) : null })); - // Lade externe Teilnehmer (nur bei allowsExternal = true) + // Lade externe Teilnehmer (nur bei allowsExternal = true) - pro Klasse if (this.allowsExternal) { try { - const extRes = await apiClient.post('/tournament/external-participants', { - clubId: this.currentClub, - tournamentId: this.selectedDate - }); - this.externalParticipants = extRes.data.map(p => ({ + const allExternalParticipants = []; + for (const classItem of this.tournamentClasses) { + try { + const extRes = await apiClient.post('/tournament/external-participants', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + classId: classItem.id + }); + // Stelle sicher, dass keine Duplikate hinzugefügt werden + extRes.data.forEach(p => { + if (!allExternalParticipants.find(existing => existing.id === p.id)) { + allExternalParticipants.push(p); + } + }); + } catch (error) { + console.error(`Fehler beim Laden der externen Teilnehmer für Klasse ${classItem.id}:`, error); + } + } + // Lade auch externe Teilnehmer ohne Klasse (falls vorhanden) + try { + const extRes = await apiClient.post('/tournament/external-participants', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + classId: null + }); + extRes.data.forEach(p => { + if (!allExternalParticipants.find(existing => existing.id === p.id)) { + allExternalParticipants.push(p); + } + }); + } catch (error) { + console.error('Fehler beim Laden der externen Teilnehmer ohne Klasse:', error); + } + this.externalParticipants = allExternalParticipants.map(p => ({ ...p, seeded: p.seeded || false, isExternal: true, @@ -1598,6 +1216,52 @@ export default { } }, + labelGender(g) { + const v = (g || 'unknown'); + if (v === 'male') return 'Männlich'; + if (v === 'female') return 'Weiblich'; + if (v === 'diverse') return 'Divers'; + return 'Unbekannt'; + }, + genderSymbol(g) { + const v = (g || 'unknown'); + if (v === 'male') return '♂'; + if (v === 'female') return '♀'; + if (v === 'diverse') return '⚧'; + return ''; + }, + getMatchPlayerNames(match) { + // Prüfe, ob das Match zu einer Doppel-Klasse gehört + const classId = match.classId; + if (classId) { + const tournamentClass = this.tournamentClasses.find(c => c.id === classId); + if (tournamentClass && tournamentClass.isDoubles) { + // Bei Doppel: Finde die Paarungen für player1Id und player2Id + const pairing1 = this.pairings.find(p => + p.classId === classId && + (p.member1Id === match.player1Id || p.external1Id === match.player1Id || + p.member2Id === match.player1Id || p.external2Id === match.player1Id) + ); + const pairing2 = this.pairings.find(p => + p.classId === classId && + (p.member1Id === match.player2Id || p.external1Id === match.player2Id || + p.member2Id === match.player2Id || p.external2Id === match.player2Id) + ); + + if (pairing1 && pairing2) { + const name1 = this.getPairingPlayerName(pairing1, 1) + ' / ' + this.getPairingPlayerName(pairing1, 2); + const name2 = this.getPairingPlayerName(pairing2, 1) + ' / ' + this.getPairingPlayerName(pairing2, 2); + return { name1, name2 }; + } + } + } + // Bei Einzel: Normale Anzeige + return { + name1: this.getPlayerName(match.player1), + name2: this.getPlayerName(match.player2) + }; + }, + async loadTournaments() { try { const d = await apiClient.get(`/tournament/${this.currentClub}`); @@ -1730,23 +1394,49 @@ export default { await this.showInfo(this.$t('messages.error'), this.$t('tournaments.pleaseSelectParticipant'), '', 'error'); return; } - const oldMap = this.participants.reduce((map, p) => { - map[p.id] = p.groupNumber - return map - }, {}) - const r = await apiClient.post('/tournament/participant', { - clubId: this.currentClub, - tournamentId: this.selectedDate, - participant: this.selectedMember - }) - this.participants = r.data.map(p => ({ - ...p, - groupNumber: - oldMap[p.id] != null - ? oldMap[p.id] - : (p.groupId || null) - })) - this.selectedMember = null + const classId = this.activeAssignmentClassId; + if (classId === undefined) { + await this.showInfo(this.$t('messages.error'), this.$t('tournaments.selectClassPrompt'), '', 'error'); + return; + } + try { + const oldMap = this.participants.reduce((map, p) => { + map[p.id] = p.groupNumber; + return map; + }, {}); + const r = await apiClient.post('/tournament/participant', { + clubId: this.currentClub, + classId: classId, + participant: this.selectedMember + }); + + // Prüfe, ob r.data ein Array ist + if (Array.isArray(r.data)) { + this.participants = r.data.map(p => ({ + ...p, + groupNumber: + oldMap[p.id] != null + ? oldMap[p.id] + : (p.groupId || null) + })); + } else { + // Falls r.data kein Array ist, lade die Teilnehmerliste neu + await this.loadParticipants(); + } + + // Nur selectedMember zurücksetzen + this.selectedMember = null; + // Lade Paarungen neu, falls eine Doppel-Klasse ausgewählt ist + if (this.selectedViewClass !== null && this.selectedViewClass !== undefined && this.selectedViewClass !== '__none__' && this.isClassDoubles(this.selectedViewClass)) { + await this.loadPairings(); + } + } catch (error) { + console.error('Fehler beim Hinzufügen des Teilnehmers:', error); + const message = safeErrorMessage(error, 'Fehler beim Hinzufügen des Teilnehmers.'); + await this.showInfo(this.$t('messages.error'), message, '', 'error'); + // Lade die Teilnehmerliste neu, um den aktuellen Stand zu haben + await this.loadParticipants(); + } }, async createGroups() { @@ -2005,13 +1695,23 @@ export default { }, async onGroupCountChange() { - await apiClient.post('/tournament/modus', { - clubId: this.currentClub, - tournamentId: this.selectedDate, - type: this.isGroupTournament ? 'groups' : 'knockout', - numberOfGroups: this.numberOfGroups - }); - await this.loadTournamentData(); + // Wenn Klassen vorhanden sind, speichere groupsPerClass, sonst numberOfGroups + if (this.tournamentClasses.length > 0) { + // Speichere groupsPerClass für die aktuelle Klasse + // Die Werte sind bereits in this.groupsPerClass gespeichert durch den setter von groupsPerClassInput + // Wir müssen nichts speichern, da groupsPerClass nur lokal verwendet wird + // Die tatsächliche Erstellung der Gruppen erfolgt über createGroups + return; + } else { + // Fallback: Verwende numberOfGroups wie bisher + await apiClient.post('/tournament/modus', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + type: this.isGroupTournament ? 'groups' : 'knockout', + numberOfGroups: this.numberOfGroups + }); + await this.loadTournamentData(); + } }, async reopenMatch(match) { @@ -2334,48 +2034,104 @@ export default { } }, - toggleParticipants() { - this.showParticipants = !this.showParticipants; - }, - toggleClasses() { - this.showClasses = !this.showClasses; + + togglePairings() { + this.showPairings = !this.showPairings; }, async loadTournamentClasses() { if (!this.selectedDate || this.selectedDate === 'new') { this.tournamentClasses = []; this.groupsPerClass = {}; + this.selectedViewClass = '__none__'; + this.showClasses = false; return; } try { const res = await apiClient.get(`/tournament/classes/${this.currentClub}/${this.selectedDate}`); - this.tournamentClasses = res.data || []; + const classes = res.data || []; + // Stelle sicher, dass isDoubles als Boolean behandelt wird + this.tournamentClasses = classes.map(classItem => ({ + ...classItem, + isDoubles: Boolean(classItem.isDoubles) + })); + // Öffne die Klassen-Sektion automatisch, wenn Klassen vorhanden sind + if (this.tournamentClasses.length > 0) { + this.showClasses = true; + } // Initialisiere groupsPerClass für jede Klasse - const newGroupsPerClass = { ...this.groupsPerClass }; + // BEHALTE bestehende Werte, setze nur fehlende auf 0 this.tournamentClasses.forEach(classItem => { - if (!(classItem.id in newGroupsPerClass)) { - newGroupsPerClass[classItem.id] = 0; + if (!(classItem.id in this.groupsPerClass) && this.groupsPerClass[classItem.id] === undefined) { + this.groupsPerClass[classItem.id] = 0; } }); - this.groupsPerClass = newGroupsPerClass; + // Initialisiere auch für "ohne Klasse" + if (!('null' in this.groupsPerClass) && this.groupsPerClass['null'] === undefined) { + this.groupsPerClass['null'] = 0; + } + // Bestehende Werte bleiben erhalten + + // Aktualisiere Auswahl für die Klassenanzeige + if (this.tournamentClasses.length === 0) { + this.selectedViewClass = '__none__'; + } else { + const validIds = this.tournamentClasses.map(c => String(c.id)); + if (this.selectedViewClass !== null && this.selectedViewClass !== '__none__' && !validIds.includes(String(this.selectedViewClass))) { + this.selectedViewClass = null; + } + } } catch (error) { console.error('Fehler beim Laden der Klassen:', error); this.tournamentClasses = []; this.groupsPerClass = {}; + this.selectedViewClass = '__none__'; } }, - async addClass() { - if (!this.newClassName || !this.newClassName.trim()) { + async handleAddClassError(message) { + await this.showInfo('Fehler', message, '', 'error'); + }, + + async addClass(data = null) { + // Verwende die übergebenen Daten, falls vorhanden, sonst die Props + // Die Daten sollten immer vorhanden sein, da die Child-Komponente sie übergibt + let className; + let isDoubles; + let gender; + console.log(data); + if (data && typeof data === 'object' && data !== null && 'name' in data) { + // Daten wurden als Objekt übergeben + className = data.name; + isDoubles = data.isDoubles !== undefined ? data.isDoubles : this.newClassIsDoubles; + gender = data.gender !== undefined ? data.gender : this.newClassGender; + } else { + // Fallback auf Props (sollte nicht passieren, aber für Sicherheit) + className = this.newClassName; + isDoubles = this.newClassIsDoubles; + gender = this.newClassGender; + } + + // Validiere: className muss ein String sein und nicht leer + // Diese Validierung sollte eigentlich nicht mehr nötig sein, da die Child-Komponente bereits validiert + // Aber wir behalten sie als Fallback + if (typeof className !== 'string' || !className.trim()) { await this.showInfo('Fehler', 'Bitte geben Sie einen Klassennamen ein!', '', 'error'); return; } try { + const minBirthYear = data?.minBirthYear !== undefined ? data.minBirthYear : this.newClassMaxBirthYear; await apiClient.post(`/tournament/class/${this.currentClub}/${this.selectedDate}`, { - name: this.newClassName.trim() + name: className.trim(), + isDoubles: isDoubles, + gender: gender, + minBirthYear: minBirthYear }); this.newClassName = ''; + this.newClassIsDoubles = false; + this.newClassGender = null; + this.newClassMaxBirthYear = null; await this.loadTournamentClasses(); } catch (error) { console.error('Fehler beim Hinzufügen der Klasse:', error); @@ -2387,6 +2143,11 @@ export default { editClass(classItem) { this.editingClassId = classItem.id; this.editingClassName = classItem.name; + // Stelle sicher, dass isDoubles als Boolean behandelt wird + this.editingClassIsDoubles = Boolean(classItem.isDoubles); + this.editingClassGender = classItem.gender || null; + this.editingClassMaxBirthYear = classItem.minBirthYear || null; + console.log('[editClass] classItem.isDoubles:', classItem.isDoubles, 'editingClassIsDoubles:', this.editingClassIsDoubles); this.$nextTick(() => { const inputs = this.$refs.classEditInput; if (inputs && inputs.length > 0) { @@ -2399,6 +2160,24 @@ export default { }); }, + handleClassInputBlur(event, classItem) { + // Prüfe, ob das Blur-Event durch einen Klick auf die Checkbox oder Buttons ausgelöst wurde + const relatedTarget = event.relatedTarget; + if (relatedTarget) { + // Wenn das verwandte Element die Checkbox, das Label oder die Buttons sind, nicht speichern + const isCheckbox = relatedTarget.type === 'checkbox' || + relatedTarget.closest('.class-doubles-checkbox') || + relatedTarget.closest('.class-gender-select') || + relatedTarget.closest('.btn-save-small') || + relatedTarget.closest('.btn-cancel-small'); + if (isCheckbox) { + return; // Nicht speichern + } + } + // Normales Blur - speichern + this.saveClassEdit(classItem); + }, + async saveClassEdit(classItem) { if (!this.editingClassName || !this.editingClassName.trim()) { await this.showInfo('Fehler', 'Bitte geben Sie einen Klassennamen ein!', '', 'error'); @@ -2407,9 +2186,15 @@ export default { } try { await apiClient.put(`/tournament/class/${this.currentClub}/${this.selectedDate}/${classItem.id}`, { - name: this.editingClassName.trim() + name: this.editingClassName.trim(), + isDoubles: this.editingClassIsDoubles, + gender: this.editingClassGender, + minBirthYear: this.editingClassMaxBirthYear }); classItem.name = this.editingClassName.trim(); + classItem.isDoubles = this.editingClassIsDoubles; + classItem.gender = this.editingClassGender; + classItem.minBirthYear = this.editingClassMaxBirthYear; this.cancelClassEdit(); } catch (error) { console.error('Fehler beim Aktualisieren der Klasse:', error); @@ -2424,6 +2209,9 @@ export default { cancelClassEdit() { this.editingClassId = null; this.editingClassName = ''; + this.editingClassIsDoubles = false; + this.editingClassGender = null; + this.editingClassMaxBirthYear = null; }, async deleteClass(classItem) { @@ -2467,24 +2255,35 @@ export default { await this.showInfo('Fehler', 'Bitte geben Sie mindestens Vorname und Nachname ein!', '', 'error'); return; } + const classId = this.activeAssignmentClassId; + if (classId === undefined) { + await this.showInfo('Fehler', this.$t('tournaments.selectClassPrompt'), '', 'error'); + return; + } try { await apiClient.post('/tournament/external-participant', { clubId: this.currentClub, - tournamentId: this.selectedDate, + classId: classId, firstName: this.newExternalParticipant.firstName, lastName: this.newExternalParticipant.lastName, club: this.newExternalParticipant.club || null, - birthDate: this.newExternalParticipant.birthDate || null + birthDate: this.newExternalParticipant.birthDate || null, + gender: this.newExternalParticipant.gender || 'unknown' }); // Leere Eingabefelder this.newExternalParticipant = { firstName: '', lastName: '', club: '', - birthDate: null + birthDate: null, + gender: 'unknown' }; // Lade Daten neu await this.loadTournamentData(); + // Lade Paarungen neu, falls eine Doppel-Klasse ausgewählt ist + if (this.selectedViewClass !== null && this.selectedViewClass !== undefined && this.selectedViewClass !== '__none__' && this.isClassDoubles(this.selectedViewClass)) { + await this.loadPairings(); + } } catch (error) { console.error('Fehler beim Hinzufügen des externen Teilnehmers:', error); const message = safeErrorMessage(error, 'Fehler beim Hinzufügen des externen Teilnehmers.'); @@ -2494,6 +2293,10 @@ export default { async loadParticipantsFromTraining() { try { + if (!this.canAssignClass) { + await this.showInfo('Hinweis', this.$t('tournaments.selectClassPrompt'), '', 'info'); + return; + } if (!this.currentTournamentDate) { await this.showInfo('Hinweis', 'Kein Turnierdatum vorhanden!', '', 'info'); return; @@ -2569,9 +2372,13 @@ export default { async addParticipantFromTraining(participant) { try { + const classId = this.activeAssignmentClassId; + if (classId === undefined) { + return; + } await apiClient.post('/tournament/participant', { clubId: this.currentClub, - tournamentId: this.selectedDate, + classId: classId, participant: participant.clubMemberId }); } catch (error) { @@ -2580,6 +2387,240 @@ export default { } }, + async loadPairings() { + if (!this.selectedDate || this.selectedDate === 'new') { + this.pairings = []; + return; + } + + // Lade Paarungen für alle Doppel-Klassen, nicht nur für die ausgewählte + const doublesClasses = this.tournamentClasses.filter(c => c.isDoubles); + if (doublesClasses.length === 0) { + this.pairings = []; + return; + } + + try { + // Lade Paarungen für alle Doppel-Klassen + const allPairings = []; + for (const classItem of doublesClasses) { + try { + const res = await apiClient.get(`/tournament/pairings/${this.currentClub}/${this.selectedDate}/${classItem.id}`); + const pairings = res.data || []; + allPairings.push(...pairings); + } catch (error) { + console.error(`Fehler beim Laden der Paarungen für Klasse ${classItem.id}:`, error); + } + } + + // Entferne Duplikate basierend auf ID + const seen = new Set(); + this.pairings = allPairings.filter(p => { + if (seen.has(p.id)) { + return false; + } + seen.add(p.id); + return true; + }); + } catch (error) { + console.error('Fehler beim Laden der Paarungen:', error); + this.pairings = []; + } + }, + + async addPairing() { + if (!this.newPairing.player1Id || !this.newPairing.player2Id || this.newPairing.player1Id === this.newPairing.player2Id) { + await this.showInfo(this.$t('messages.error'), 'Bitte wählen Sie zwei verschiedene Spieler aus!', '', 'error'); + return; + } + // Prüfe auf Duplikate im Frontend (in beiden Richtungen) + const existingPairing = this.pairings.find(p => { + const p1Id = p.member1?.id || p.external1?.id; + const p2Id = p.member2?.id || p.external2?.id; + return (p1Id === this.newPairing.player1Id && p2Id === this.newPairing.player2Id) || + (p1Id === this.newPairing.player2Id && p2Id === this.newPairing.player1Id); + }); + if (existingPairing) { + await this.showInfo(this.$t('messages.error'), 'Diese Paarung existiert bereits!', '', 'error'); + return; + } + // Ermittle automatisch, ob die Teilnehmer intern oder extern sind + const participant1 = this.allParticipantsList().find(p => p.id === this.newPairing.player1Id); + const participant2 = this.allParticipantsList().find(p => p.id === this.newPairing.player2Id); + if (!participant1 || !participant2) { + await this.showInfo(this.$t('messages.error'), 'Teilnehmer nicht gefunden!', '', 'error'); + return; + } + const player1Type = participant1.isExternal ? 'external' : 'member'; + const player2Type = participant2.isExternal ? 'external' : 'member'; + try { + await apiClient.post(`/tournament/pairing/${this.currentClub}/${this.selectedDate}/${this.selectedViewClass}`, { + player1Type: player1Type, + player1Id: this.newPairing.player1Id, + player2Type: player2Type, + player2Id: this.newPairing.player2Id, + seeded: this.newPairing.seeded + }); + // Reset form + this.newPairing = { + player1Id: null, + player2Id: null, + seeded: false + }; + await this.loadPairings(); + } catch (error) { + console.error('Fehler beim Hinzufügen der Paarung:', error); + const message = safeErrorMessage(error, 'Fehler beim Hinzufügen der Paarung.'); + await this.showInfo(this.$t('messages.error'), message, '', 'error'); + } + }, + + async removePairing(pairing) { + try { + await apiClient.delete(`/tournament/pairing/${this.currentClub}/${this.selectedDate}/${pairing.id}`); + await this.loadPairings(); + } catch (error) { + console.error('Fehler beim Löschen der Paarung:', error); + const message = safeErrorMessage(error, 'Fehler beim Löschen der Paarung.'); + await this.showInfo(this.$t('messages.error'), message, '', 'error'); + } + }, + + async createRandomPairings() { + const participants = this.getParticipantsForClass(this.selectedViewClass); + if (participants.length < 2) { + await this.showInfo(this.$t('messages.error'), 'Mindestens 2 Teilnehmer erforderlich für Paarungen!', '', 'error'); + return; + } + + // Teile Teilnehmer in gesetzte und nicht gesetzte + const seeded = participants.filter(p => p.seeded); + const unseeded = participants.filter(p => !p.seeded); + + // Prüfe: Wenn mehr gesetzte als nicht gesetzte Spieler, Fehler + if (seeded.length > unseeded.length) { + await this.showInfo(this.$t('messages.error'), this.$t('tournaments.errorMoreSeededThanUnseeded'), '', 'error'); + return; + } + + // Bestätigung vor dem Löschen bestehender Paarungen + if (this.pairings.length > 0) { + const confirmed = await this.showConfirm( + this.$t('messages.confirm'), + 'Bestehende Paarungen werden gelöscht. Fortfahren?', + '', + 'warning' + ); + if (!confirmed) { + return; + } + } + + // Lösche alle bestehenden Paarungen + for (const pairing of this.pairings) { + try { + await apiClient.delete(`/tournament/pairing/${this.currentClub}/${this.selectedDate}/${pairing.id}`); + } catch (error) { + console.error('Fehler beim Löschen der Paarung:', error); + } + } + + // Erstelle zufällige Paarungen + const shuffledSeeded = [...seeded].sort(() => Math.random() - 0.5); + const shuffledUnseeded = [...unseeded].sort(() => Math.random() - 0.5); + + const newPairings = []; + let unseededIndex = 0; + + // Paare jeden gesetzten Spieler mit einem ungesetzten + for (const seededPlayer of shuffledSeeded) { + if (unseededIndex < shuffledUnseeded.length) { + newPairings.push({ + player1: seededPlayer, + player2: shuffledUnseeded[unseededIndex] + }); + unseededIndex++; + } + } + + // Wenn noch ungesetzte Spieler übrig sind, paare sie untereinander + const remainingUnseeded = shuffledUnseeded.slice(unseededIndex); + if (remainingUnseeded.length >= 2) { + // Paare die übrigen ungesetzten untereinander + for (let i = 0; i < remainingUnseeded.length - 1; i += 2) { + newPairings.push({ + player1: remainingUnseeded[i], + player2: remainingUnseeded[i + 1] + }); + } + } + // Wenn ungerade Anzahl ungesetzter übrig, bleibt einer allein (wird nicht gepaart) + + // Erstelle die Paarungen im Backend + let successCount = 0; + let errorCount = 0; + for (const pairing of newPairings) { + try { + const participant1 = pairing.player1; + const participant2 = pairing.player2; + const player1Type = participant1.isExternal ? 'external' : 'member'; + const player2Type = participant2.isExternal ? 'external' : 'member'; + + await apiClient.post(`/tournament/pairing/${this.currentClub}/${this.selectedDate}/${this.selectedViewClass}`, { + player1Type: player1Type, + player1Id: participant1.id, + player2Type: player2Type, + player2Id: participant2.id, + seeded: false // Paarungen sind nicht gesetzt, nur einzelne Spieler können gesetzt sein + }); + successCount++; + } catch (error) { + console.error('Fehler beim Erstellen der Paarung:', error); + errorCount++; + } + } + + // Lade Pairings neu + await this.loadPairings(); + + if (errorCount > 0) { + await this.showInfo(this.$t('messages.warning'), `${successCount} Paarungen erstellt, ${errorCount} Fehler.`, '', 'warning'); + } else { + await this.showInfo(this.$t('messages.success'), this.$t('tournaments.randomPairingsCreated'), '', 'success'); + } + }, + + async updatePairingSeeded(pairing, event) { + const seeded = event.target.checked; + try { + await apiClient.put(`/tournament/pairing/${this.currentClub}/${this.selectedDate}/${pairing.id}`, { + seeded: seeded + }); + pairing.seeded = seeded; + } catch (error) { + console.error('Fehler beim Aktualisieren des Gesetzt-Status:', error); + // Bei Fehler: Lade Daten neu + await this.loadPairings(); + } + }, + + getPairingPlayerName(pairing, playerNumber) { + if (playerNumber === 1) { + if (pairing.member1 && pairing.member1.member) { + return `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}`; + } else if (pairing.external1) { + return `${pairing.external1.firstName} ${pairing.external1.lastName}`; + } + } else if (playerNumber === 2) { + if (pairing.member2 && pairing.member2.member) { + return `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}`; + } else if (pairing.external2) { + return `${pairing.external2.firstName} ${pairing.external2.lastName}`; + } + } + return this.$t('tournaments.unknown'); + }, + getMatchDisplayText(player1Id, player2Id, groupId) { const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId); if (!liveResult) return '-'; @@ -2771,6 +2812,47 @@ export default { min-width: 200px; } +/* Tab-System */ +.tournament-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 2px solid #dee2e6; +} + +.tab-button { + padding: 0.75rem 1.5rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 1em; + color: #6c757d; + transition: all 0.2s; + margin-bottom: -2px; +} + +.tab-button:hover { + color: #495057; + background-color: #f8f9fa; +} + +.tab-button.active { + color: #28a745; + border-bottom-color: #28a745; + font-weight: 500; +} + +.tab-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tab-content { + padding: 1rem 0; + display: block !important; /* Überschreibe globale display: none Regel */ +} + .participants, .group-controls, .groups-overview, @@ -3100,6 +3182,7 @@ button { gap: 0.5rem; align-items: center; flex-wrap: wrap; + width: fit-content; } .external-input { @@ -3111,8 +3194,8 @@ button { } .member-select { - flex: 1; min-width: 200px; + max-width: 300px; padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; @@ -3198,6 +3281,126 @@ button { min-width: 200px; } +.class-type-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background-color: #e0e0e0; + color: #666; + margin-left: 8px; +} + +.class-type-badge.doubles { + background-color: #4caf50; + color: white; +} + +.class-doubles-checkbox { + display: flex; + align-items: center; + gap: 6px; + margin-left: 12px; + font-size: 14px; + cursor: pointer; +} + +.class-doubles-checkbox input[type="checkbox"] { + cursor: pointer; +} + +.class-gender-select { + padding: 0.4rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.9em; + min-width: 100px; + background-color: white; +} + +.class-gender-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.85em; + margin-left: 0.5rem; + background-color: #e9ecef; + color: #495057; +} + +.class-gender-badge.gender-male { + background-color: #cfe2ff; + color: #084298; +} + +.class-gender-badge.gender-female { + background-color: #f8d7da; + color: #842029; +} + +.class-gender-badge.gender-mixed { + background-color: #fff3cd; + color: #664d03; +} + +.groups-per-class { + margin-top: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.groups-per-class h4 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +.groups-per-class-hint { + font-size: 0.9em; + color: #666; + margin-bottom: 1rem; + font-style: italic; +} + +.class-group-config { + margin-bottom: 0.75rem; +} + +.class-group-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95em; +} + +.class-group-name { + min-width: 150px; + font-weight: 500; +} + +.class-group-type { + font-size: 0.85em; + color: #666; + font-style: italic; +} + +.class-group-type.doubles { + color: #4caf50; + font-weight: 500; +} + +.class-group-input { + width: 80px; + padding: 0.25rem 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + text-align: center; +} + +.class-group-unit { + color: #666; + font-size: 0.9em; +} + .class-name-input { flex: 1; padding: 0.4rem; @@ -3246,6 +3449,79 @@ button { color: #ccc; } +.participants-class-section { + margin-bottom: 2rem; +} + +.participants-class-header { + margin: 1rem 0 0.5rem 0; + padding: 0.5rem; + background-color: #e9ecef; + border-radius: 4px; + font-size: 1.1em; + font-weight: 600; +} + +.class-type-badge-small { + font-size: 0.85em; + font-weight: normal; + color: #666; +} + +.class-type-badge-small.doubles { + color: #4caf50; + font-weight: 500; +} + +.class-selection-section { + margin: 1.5rem 0; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 4px; + border: 1px solid #dee2e6; +} + +.participants-class-filter { + margin-bottom: 0; + padding: 0; +} + +.participants-class-filter label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; +} + +.class-filter-select { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.95em; + min-width: 200px; +} + +.class-assignment-info { + margin-bottom: 1rem; + padding: 0.5rem 0.75rem; + background-color: #f1f3f5; + border-radius: 4px; + display: flex; + gap: 0.5rem; + align-items: center; + font-size: 0.9em; +} + +.class-assignment-info.no-selection { + border: 1px dashed #ff6b6b; + background-color: #fff5f5; + color: #ff6b6b; +} + +.class-assignment-info .label { + font-weight: 600; +} + .group-table table { font-size: 0.9em; } @@ -3440,4 +3716,159 @@ tbody tr:hover:not(.active-match) { color: #45a049 !important; transform: none !important; } +.pairings-section { + margin-top: 2rem; + padding: 1rem; + border: 1px solid #dee2e6; + border-radius: 4px; + background-color: #f8f9fa; +} + +.pairings-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 0.5rem 0; +} + +.pairings-header h4 { + margin: 0; +} + +.pairings-content { + margin-top: 1rem; +} + +.add-pairing { + margin-bottom: 1.5rem; + padding: 1rem; + background-color: white; + border-radius: 4px; + border: 1px solid #dee2e6; +} + +.add-pairing h5 { + margin: 0 0 0.75rem 0; + font-size: 0.9em; + color: #495057; +} + +.pairing-form { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + width: fit-content; +} + +.pairing-player-type { + padding: 0.4rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.9em; + min-width: 100px; +} + +.pairing-player-select { + min-width: 180px; + max-width: 250px; + padding: 0.4rem; + border: 1px solid #ccc; + border-radius: 4px; +} + +.pairing-separator { + font-weight: bold; + font-size: 1.2em; + color: #495057; +} + +.pairing-seeded-label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.9em; + white-space: nowrap; +} + +.pairings-list { + margin-top: 1rem; +} + +.pairings-table { + width: 100%; + border-collapse: collapse; + background-color: white; + border-radius: 4px; + overflow: hidden; +} + +.pairings-table thead { + background-color: #28a745; + color: white; +} + +.pairings-table th, +.pairings-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #dee2e6; +} + +.pairings-table tbody tr:hover { + background-color: #f8f9fa; +} + +.random-pairing-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #dee2e6; +} + +.btn-random-pairings { + padding: 0.5rem 1rem; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; +} + +.btn-random-pairings:hover { + background-color: #218838; +} + +.btn-random-pairings:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +.random-pairing-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #dee2e6; +} + +.btn-random-pairings { + padding: 0.5rem 1rem; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; +} + +.btn-random-pairings:hover { + background-color: #218838; +} + +.btn-random-pairings:disabled { + background-color: #6c757d; + cursor: not-allowed; +} \ No newline at end of file