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 @@
+
+
+ {{ $t('tournaments.classes') }}
+
+
+
{{ $t('tournaments.noClassesYet') }}
+
+
+
+
+
+
+
+
+ {{ classItem.name }}
+
+ ({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
+
+
+
+ {{ classItem.gender === 'male' ? $t('members.genderMale') : classItem.gender === 'female' ? $t('members.genderFemale') : $t('tournaments.genderMixed') }}
+
+
+ ≥ {{ classItem.minBirthYear }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
$emit('add-class', data)"
+ @add-class-error="$emit('add-class-error', $event)"
+ @handle-class-input-blur="$emit('handle-class-input-blur', $event[0], $event[1])"
+ @update:editingClassName="$emit('update:editingClassName', $event)"
+ @update:editingClassIsDoubles="$emit('update:editingClassIsDoubles', $event)"
+ @update:editingClassGender="$emit('update:editingClassGender', $event)"
+ @update:editingClassMinBirthYear="$emit('update:editingClassMinBirthYear', $event)"
+ @update:newClassName="$emit('update:newClassName', $event)"
+ @update:newClassIsDoubles="$emit('update:newClassIsDoubles', $event)"
+ @update:newClassGender="$emit('update:newClassGender', $event)"
+ @update:newClassMinBirthYear="$emit('update:newClassMinBirthYear', $event)"
+ />
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ $t('tournaments.groupsOverview') }}
+
+
+
+
+
+
+
{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}
+
+
+
+ | {{ $t('tournaments.index') }} |
+ {{ $t('tournaments.position') }} |
+ {{ $t('tournaments.player') }} |
+ {{ $t('tournaments.points') }} |
+ {{ $t('tournaments.sets') }} |
+ {{ $t('tournaments.diff') }} |
+ {{ $t('tournaments.pointsRatio') }} |
+
+ G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
+ |
+ {{ $t('tournaments.livePosition') }} |
+
+
+
+
+ | G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }} |
+ {{ pl.position }}. |
+ ★{{ pl.name }} |
+ {{ pl.points }} |
+ {{ pl.setsWon }}:{{ pl.setsLost }} |
+
+ {{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
+ |
+
+ {{ pl.pointsWon }}:{{ pl.pointsLost }} ({{ (pl.pointsWon - pl.pointsLost) >= 0 ? '+' + (pl.pointsWon - pl.pointsLost) : (pl.pointsWon - pl.pointsLost) }})
+ |
+
+
+
+ {{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
+
+ -
+ |
+ {{ getLivePosition(pl.id, group.groupId) }}. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ $t('tournaments.participants') }}
+
+
+
+
+
{{ $t('tournaments.addClubMember') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('tournaments.addPairing') }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ $t('tournaments.groupMatches') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('tournaments.koRound') }}
+
+
+
+ Rangliste
+
+
+ {{ getClassName(classKey) }}
+
+
+
+ | Platz |
+ Spieler |
+
+
+
+
+ | {{ entry.position }}. |
+
+ {{ entry.member.firstName }}
+ {{ entry.member.lastName }}
+ |
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- {{ classItem.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $t('tournaments.addClubMember') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('tournaments.groupsOverview') }}
-
-
-
-
-
-
{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}
-
-
-
- | {{ $t('tournaments.index') }} |
- {{ $t('tournaments.position') }} |
- {{ $t('tournaments.player') }} |
- {{ $t('tournaments.points') }} |
- {{ $t('tournaments.sets') }} |
- {{ $t('tournaments.diff') }} |
- {{ $t('tournaments.pointsRatio') }} |
-
- G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
- |
- {{ $t('tournaments.livePosition') }} |
-
-
-
-
- | G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }} |
- {{ pl.position }}. |
- ★{{ pl.name }} |
- {{ pl.points }} |
- {{ pl.setsWon }}:{{ pl.setsLost }} |
-
- {{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
- |
-
- {{ pl.pointsWon }}:{{ pl.pointsLost }} ({{ (pl.pointsWon - pl.pointsLost) >= 0 ? '+' + (pl.pointsWon - pl.pointsLost) : (pl.pointsWon - pl.pointsLost) }})
- |
-
-
-
- {{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
-
- -
- |
- {{ getLivePosition(pl.id, group.groupId) }}. |
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
- {{ $t('tournaments.groupMatches') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('tournaments.koRound') }}
-
-
-
- Rangliste
-
-
- {{ getClassName(classKey) }}
-
-
-
- | Platz |
- Spieler |
-
-
-
-
- | {{ entry.position }}. |
-
- {{ entry.member.firstName }}
- {{ entry.member.lastName }}
- |
-
-
-
-
-
-
({ ...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