Implement external participant management and tournament class features

This commit enhances the tournament management system by introducing functionality for handling external participants and tournament classes. New methods are added to the `tournamentController` and `tournamentService` for adding, retrieving, updating, and removing external participants, as well as managing tournament classes. The backend models are updated to support these features, including new relationships and attributes. The frontend is also updated to allow users to manage external participants and classes, improving the overall user experience and interactivity in tournament management.
This commit is contained in:
Torsten Schulz (local)
2025-11-14 22:36:51 +01:00
parent 3334d76688
commit d08835e206
24 changed files with 2798 additions and 326 deletions

View File

@@ -18,9 +18,9 @@ export const getTournaments = async (req, res) => {
// 2. Neues Turnier anlegen
export const addTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentName, date, winningSets } = req.body;
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
try {
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets);
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
// Emit Socket-Event
if (clubId && tournament && tournament.id) {
emitTournamentChanged(clubId, tournament.id);
@@ -82,9 +82,24 @@ export const setModus = async (req, res) => {
// 6. Gruppen-Strukturen anlegen (leere Gruppen)
export const createGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
const { clubId, tournamentId, numberOfGroups } = req.body;
try {
await tournamentService.createGroups(token, clubId, tournamentId);
await tournamentService.createGroups(token, clubId, tournamentId, numberOfGroups);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 6b. Gruppen-Strukturen pro Klasse anlegen
export const createGroupsPerClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, groupsPerClass } = req.body;
try {
await tournamentService.createGroupsPerClass(token, clubId, tournamentId, groupsPerClass);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
@@ -360,4 +375,128 @@ export const setMatchActive = async (req, res) => {
res.status(500).json({ error: err.message });
}
};
// Externe Teilnehmer hinzufügen
export const addExternalParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, firstName, lastName, club, birthDate } = req.body;
try {
await tournamentService.addExternalParticipant(token, clubId, tournamentId, firstName, lastName, club, birthDate);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' });
} catch (error) {
console.error('[addExternalParticipant] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Externe Teilnehmer abrufen
export const getExternalParticipants = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (error) {
console.error('[getExternalParticipants] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Externe Teilnehmer löschen
export const removeExternalParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.body;
try {
await tournamentService.removeExternalParticipant(token, clubId, tournamentId, participantId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Externer Teilnehmer entfernt' });
} catch (error) {
console.error('[removeExternalParticipant] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Gesetzt-Status für externe Teilnehmer aktualisieren
export const updateExternalParticipantSeeded = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { seeded } = req.body;
try {
await tournamentService.updateExternalParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
} catch (error) {
console.error('[updateExternalParticipantSeeded] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Tournament Classes
export const getTournamentClasses = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
try {
const classes = await tournamentService.getTournamentClasses(token, clubId, tournamentId);
res.status(200).json(classes);
} catch (error) {
console.error('[getTournamentClasses] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const addTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name } = req.body;
try {
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {
console.error('[addTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const updateTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
const { name, sortOrder } = req.body;
try {
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {
console.error('[updateTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const deleteTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
try {
await tournamentService.deleteTournamentClass(token, clubId, tournamentId, classId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Klasse gelöscht' });
} catch (error) {
console.error('[deleteTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const updateParticipantClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { classId, isExternal } = req.body;
try {
await tournamentService.updateParticipantClass(token, clubId, tournamentId, participantId, classId, isExternal);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Klasse aktualisiert' });
} catch (error) {
console.error('[updateParticipantClass] Error:', error);
res.status(500).json({ error: error.message });
}
};

View File

@@ -0,0 +1,22 @@
-- Migration: Add 'allows_external' column to tournament table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'allows_external';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `winning_sets`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -0,0 +1,27 @@
-- Migration: Add 'class_id' column to external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'external_tournament_participant';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `external_tournament_participant` ADD COLUMN `class_id` INT(11) NULL AFTER `seeded`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,27 @@
-- Migration: Add 'class_id' column to tournament_group table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_group';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `tournament_group` ADD COLUMN `class_id` INT(11) NULL AFTER `tournament_id`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,27 @@
-- Migration: Add 'class_id' column to tournament_match table
-- Date: 2025-01-16
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_match';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `tournament_match` ADD COLUMN `class_id` INT(11) NULL AFTER `group_id`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,22 @@
-- Migration: Add 'class_id' column to tournament_member table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_member';
SET @columnname = 'class_id';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` INT(11) NULL AFTER `seeded`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -0,0 +1,41 @@
-- Migration: Change 'ttr' column to 'birth_date' in external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'external_tournament_participant';
SET @oldcolumnname = 'ttr';
SET @newcolumnname = 'birth_date';
-- Check if old column exists
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @oldcolumnname)
) > 0,
CONCAT('ALTER TABLE `', @tablename, '` CHANGE COLUMN `', @oldcolumnname, '` `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`'),
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;
-- If old column didn't exist, check if new column exists and add it if not
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @newcolumnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -0,0 +1,22 @@
-- Migration: Create external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
CREATE TABLE IF NOT EXISTS `external_tournament_participant` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tournament_id` INT(11) NOT NULL,
`group_id` INT(11) NULL,
`first_name` VARCHAR(255) NOT NULL,
`last_name` VARCHAR(255) NOT NULL,
`club` VARCHAR(255) NULL,
`birth_date` VARCHAR(255) NULL,
`seeded` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_tournament_id` (`tournament_id`),
INDEX `idx_group_id` (`group_id`),
CONSTRAINT `fk_external_participant_tournament` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_external_participant_group` FOREIGN KEY (`group_id`) REFERENCES `tournament_group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,16 @@
-- Migration: Create tournament_class table
-- Date: 2025-01-15
-- For MariaDB/MySQL
CREATE TABLE IF NOT EXISTS `tournament_class` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tournament_id` INT(11) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `tournament_id` (`tournament_id`),
CONSTRAINT `tournament_class_ibfk_1` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,89 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const ExternalTournamentParticipant = sequelize.define('ExternalTournamentParticipant', {
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
},
groupId: {
type: DataTypes.INTEGER,
autoIncrement: false,
allowNull: true
},
firstName: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
const encryptedValue = encryptData(value);
this.setDataValue('firstName', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('firstName');
return decryptData(encryptedValue);
}
},
lastName: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
const encryptedValue = encryptData(value);
this.setDataValue('lastName', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('lastName');
return decryptData(encryptedValue);
}
},
club: {
type: DataTypes.STRING,
allowNull: true,
set(value) {
if (!value) {
this.setDataValue('club', null);
return;
}
const encryptedValue = encryptData(value);
this.setDataValue('club', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('club');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
birthDate: {
type: DataTypes.STRING,
allowNull: true,
set(value) {
if (!value) {
this.setDataValue('birthDate', null);
return;
}
const encryptedValue = encryptData(value || '');
this.setDataValue('birthDate', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('birthDate');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
seeded: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
classId: {
type: DataTypes.INTEGER,
allowNull: true
}
}, {
underscored: true,
tableName: 'external_tournament_participant',
timestamps: true,
});
export default ExternalTournamentParticipant;

View File

@@ -34,6 +34,11 @@ const Tournament = sequelize.define('Tournament', {
allowNull: false,
defaultValue: 3,
},
allowsExternal: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
}, {
underscored: true,
tableName: 'tournament',

View File

@@ -0,0 +1,38 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Tournament from './Tournament.js';
const TournamentClass = sequelize.define('TournamentClass', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Tournament,
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
name: {
type: DataTypes.STRING,
allowNull: false
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
underscored: true,
tableName: 'tournament_class',
timestamps: true
});
export default TournamentClass;

View File

@@ -12,6 +12,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', {
type: DataTypes.INTEGER,
allowNull: false
},
classId: {
type: DataTypes.INTEGER,
allowNull: true
},
}, {
underscored: true,
tableName: 'tournament_group',

View File

@@ -25,6 +25,10 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
classId: {
type: DataTypes.INTEGER,
allowNull: true,
},
groupRound: {
type: DataTypes.INTEGER,
allowNull: true,

View File

@@ -21,6 +21,10 @@ const TournamentMember = sequelize.define('TournamentMember', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
classId: {
type: DataTypes.INTEGER,
allowNull: true
}
}, {
underscored: true,

View File

@@ -27,9 +27,11 @@ import Group from './Group.js';
import GroupActivity from './GroupActivity.js';
import Tournament from './Tournament.js';
import TournamentGroup from './TournamentGroup.js';
import TournamentClass from './TournamentClass.js';
import TournamentMember from './TournamentMember.js';
import TournamentMatch from './TournamentMatch.js';
import TournamentResult from './TournamentResult.js';
import ExternalTournamentParticipant from './ExternalTournamentParticipant.js';
import Accident from './Accident.js';
import UserToken from './UserToken.js';
import OfficialTournament from './OfficialTournament.js';
@@ -201,6 +203,15 @@ Member.hasMany(TournamentMember, { foreignKey: 'clubMemberId', as: 'tournamentGr
TournamentMember.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentMember, { foreignKey: 'tournamentId', as: 'tournamentMembers' });
TournamentMember.belongsTo(TournamentClass, {
foreignKey: 'classId',
as: 'class',
constraints: false
});
TournamentClass.hasMany(TournamentMember, {
foreignKey: 'classId',
as: 'members'
});
TournamentMatch.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentMatch, { foreignKey: 'tournamentId', as: 'tournamentMatches' });
@@ -227,6 +238,33 @@ TournamentMatch.belongsTo(TournamentMember, { foreignKey: 'player2Id', as: 'play
TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player1Id', as: 'player1Matches' });
TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player2Id', as: 'player2Matches' });
// Tournament Classes
TournamentClass.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentClass, { foreignKey: 'tournamentId', as: 'classes' });
// External Tournament Participants
ExternalTournamentParticipant.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(ExternalTournamentParticipant, { foreignKey: 'tournamentId', as: 'externalParticipants' });
ExternalTournamentParticipant.belongsTo(TournamentGroup, {
foreignKey: 'groupId',
targetKey: 'id',
as: 'group',
constraints: false
});
TournamentGroup.hasMany(ExternalTournamentParticipant, {
foreignKey: 'groupId',
as: 'externalGroupMembers'
});
ExternalTournamentParticipant.belongsTo(TournamentClass, {
foreignKey: 'classId',
as: 'class',
constraints: false
});
TournamentClass.hasMany(ExternalTournamentParticipant, {
foreignKey: 'classId',
as: 'externalParticipants'
});
Accident.belongsTo(Member, { foreignKey: 'memberId', as: 'members' });
Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' });
@@ -283,9 +321,11 @@ export {
GroupActivity,
Tournament,
TournamentGroup,
TournamentClass,
TournamentMember,
TournamentMatch,
TournamentResult,
ExternalTournamentParticipant,
Accident,
UserToken,
OfficialTournament,

View File

@@ -23,6 +23,16 @@ import {
reopenMatch,
deleteKnockoutMatches,
setMatchActive,
addExternalParticipant,
getExternalParticipants,
removeExternalParticipant,
updateExternalParticipantSeeded,
getTournamentClasses,
addTournamentClass,
updateTournamentClass,
deleteTournamentClass,
updateParticipantClass,
createGroupsPerClass,
} from '../controllers/tournamentController.js';
import { authenticate } from '../middleware/authMiddleware.js';
@@ -36,11 +46,12 @@ router.post('/modus', authenticate, setModus);
router.post('/groups/reset', authenticate, resetGroups);
router.post('/matches/reset', authenticate, resetMatches);
router.put('/groups', authenticate, createGroups);
router.post('/groups/create', authenticate, createGroupsPerClass);
router.post('/groups', authenticate, fillGroups);
router.get('/groups', authenticate, getGroups);
router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post("/match/reopen", authenticate, reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
@@ -48,8 +59,21 @@ router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);
router.delete("/matches/knockout", deleteKnockoutMatches);
router.delete("/matches/knockout", authenticate, deleteKnockoutMatches);
router.post('/groups/manual', authenticate, manualAssignGroups);
router.post('/', authenticate, addTournament);
// Externe Teilnehmer
router.post('/external-participant', authenticate, addExternalParticipant);
router.post('/external-participants', authenticate, getExternalParticipants);
router.delete('/external-participant', authenticate, removeExternalParticipant);
router.put('/external-participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateExternalParticipantSeeded);
// Tournament Classes
router.get('/classes/:clubId/:tournamentId', authenticate, getTournamentClasses);
router.post('/class/:clubId/:tournamentId', authenticate, addTournamentClass);
router.put('/class/:clubId/:tournamentId/:classId', authenticate, updateTournamentClass);
router.delete('/class/:clubId/:tournamentId/:classId', authenticate, deleteTournamentClass);
router.put('/participant/:clubId/:tournamentId/:participantId/class', authenticate, updateParticipantClass);
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -95,9 +95,13 @@
<span class="nav-icon">🏆</span>
Interne Turniere
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Offizielle Turniere">
<router-link v-if="hasPermission('tournaments', 'read')" to="/external-tournaments" class="nav-link" title="Turniere mit Externen">
<span class="nav-icon">🌐</span>
Offene Turniere
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Turnierteilnahmen">
<span class="nav-icon">📄</span>
Offizielle Turniere
Turnierteilnahmen
</router-link>
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon"></span>

View File

@@ -58,6 +58,7 @@ class PDFGenerator {
this.pdf.addPage();
this.xPos = this.margin;
this.yPos = this.position;
this.cursorY = this.margin;
this.isLeftColumn = true;
}
@@ -606,6 +607,552 @@ class PDFGenerator {
this.cursorY = y + 10;
}
addTournamentPDF(tournamentName, tournamentDate, groupsByClass, groupRankings, matchesByClassAndGroup, getPlayerName, knockoutRanking, participants, hasKnockoutMatches, knockoutMatches) {
// Header
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(16);
this.pdf.text(tournamentName || 'Turnier', this.margin, this.cursorY);
this.cursorY += 8;
if (tournamentDate) {
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(12);
const formattedDate = new Date(tournamentDate).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
this.pdf.text(`Datum: ${formattedDate}`, this.margin, this.cursorY);
this.cursorY += 10;
}
// 1. Gesamt-Ranking nach Klassen (nur Platz und Spieler)
// knockoutRanking und participants werden als zusätzliche Parameter übergeben
// Verwende K.O.-Ranking nur wenn es vorhanden ist UND K.O.-Runden existieren
const useKnockoutRanking = knockoutRanking && knockoutRanking.length > 0 && hasKnockoutMatches;
this.addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, useKnockoutRanking ? knockoutRanking : null, participants);
// 2. Gruppen-Matrizen mit Ergebnissen (neue Seite)
this.addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName);
// 4. Alle Spiele nach Klasse und Gruppe sortiert (inkl. K.O.-Runden)
this.addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches);
}
addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, knockoutRanking, participants) {
// Wenn K.O.-Runden vorhanden sind, verwende diese für das Ranking
if (knockoutRanking && knockoutRanking.length > 0) {
// Erstelle Mapping von Member-ID zu classId
// Für interne Teilnehmer: member.id -> classId
const memberClassMap = {};
if (participants) {
participants.forEach(p => {
// Interne Teilnehmer haben ein member-Objekt
if (p.member && p.member.id) {
memberClassMap[p.member.id] = p.classId;
}
});
}
// Gruppiere K.O.-Ranking nach Klassen
// entry.classId sollte bereits vorhanden sein (aus extendedRankingList)
const rankingByClass = {};
knockoutRanking.forEach(entry => {
// Verwende classId direkt aus entry, falls vorhanden
let classId = entry.classId;
// Fallback: Suche über memberId, falls classId nicht vorhanden
if (classId == null && entry.member) {
const memberId = entry.member.id;
if (memberId && participants) {
// Suche im Mapping
classId = memberClassMap[memberId] || null;
// Falls nicht gefunden, suche direkt in participants
if (!classId) {
const participant = participants.find(p =>
p.member && p.member.id === memberId
);
if (participant) {
classId = participant.classId;
}
}
}
}
const classKey = classId != null ? String(classId) : 'null';
if (!rankingByClass[classKey]) {
rankingByClass[classKey] = [];
}
const playerName = entry.member
? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim()
: getPlayerName(entry.player || entry);
rankingByClass[classKey].push({
position: entry.position,
name: playerName
});
});
// Sortiere innerhalb jeder Klasse nach Position
Object.keys(rankingByClass).forEach(classKey => {
rankingByClass[classKey].sort((a, b) => a.position - b.position);
});
// Zeige Rankings nach Klassen (sortiert nach classId)
Object.entries(rankingByClass).sort((a, b) => {
const aNum = a[0] === 'null' ? 999999 : parseInt(a[0]);
const bNum = b[0] === 'null' ? 999999 : parseInt(b[0]);
return aNum - bNum;
}).forEach(([classId, players]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Prüfe ob neue Seite nötig
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Klassen-Überschrift
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
// Zeige Spieler
players.forEach(player => {
if (this.cursorY > 280) {
this.addNewPage();
this.cursorY = this.margin;
// Klassen-Überschrift erneut anzeigen bei Seitenwechsel
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
}
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(11);
const playerText = `${player.position}. ${player.name}`;
this.pdf.text(playerText, this.margin, this.cursorY);
this.cursorY += 7;
});
// Abstand nach Klasse
this.cursorY += 5;
});
} else {
// Fallback: Verwende Gruppen-Rankings (nur wenn keine K.O.-Runden)
// Aber: Wenn es K.O.-Runden gibt, aber rankingList leer ist, bedeutet das,
// dass die K.O.-Runden noch nicht abgeschlossen sind
// In diesem Fall sollten wir trotzdem die Gruppen-Rankings verwenden
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Prüfe ob neue Seite nötig
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Klassen-Überschrift
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
// Sammle alle Spieler aus allen Gruppen dieser Klasse
// WICHTIG: Zeige alle Spieler, nicht nur die ersten Plätze
const allPlayers = [];
groups.forEach(group => {
const rankings = groupRankings[group.groupId] || [];
rankings.forEach(player => {
allPlayers.push({
position: player.position,
name: player.name,
seeded: player.seeded
});
});
});
// Sortiere nach Position (1, 1, 2, 2, 3, 3, etc.)
allPlayers.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
// Bei gleicher Position alphabetisch sortieren
return a.name.localeCompare(b.name);
});
// Erstelle einfache Liste: nur Platz und Spieler
allPlayers.forEach(player => {
if (this.cursorY > 280) {
this.addNewPage();
this.cursorY = this.margin;
// Klassen-Überschrift erneut anzeigen bei Seitenwechsel
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
}
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(11);
const playerText = `${player.position}. ${player.seeded ? '★ ' : ''}${player.name}`;
this.pdf.text(playerText, this.margin, this.cursorY);
this.cursorY += 7;
});
// Abstand nach Klasse
this.cursorY += 5;
});
}
}
addTournamentTables(groupsByClass, groupRankings, getPlayerName) {
// Für jede Klasse
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
groups.forEach(group => {
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(12);
let title = `Gruppe ${group.groupNumber}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 8;
// Tabelle mit Rankings
const rankings = groupRankings[group.groupId] || [];
if (rankings.length > 0) {
const head = [['Platz', 'Spieler', 'Punkte', 'Sätze', 'Diff']];
const body = rankings.map(p => [
`${p.position}.`,
(p.seeded ? '★ ' : '') + p.name,
p.points.toString(),
`${p.setsWon}:${p.setsLost}`,
(p.setDiff >= 0 ? '+' : '') + p.setDiff.toString()
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 10 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
}
});
});
}
addTournamentKnockoutRanking(knockoutRanking, getPlayerName) {
// Prüfe ob neue Seite nötig
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Gesamt-Ranking (K.O.-Runden)', this.margin, this.cursorY);
this.cursorY += 10;
// Ranking-Tabelle
const head = [['Platz', 'Spieler']];
const body = knockoutRanking.map(entry => {
const playerName = entry.member
? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim()
: getPlayerName(entry.player || entry);
return [
`${entry.position}.`,
playerName
];
});
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 11 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
}
addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName) {
// Neue Seite für Matrizen
this.addNewPage();
this.cursorY = this.margin;
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Gruppen-Matrizen', this.margin, this.cursorY);
this.cursorY += 10;
// Für jede Klasse
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
groups.forEach(group => {
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
const rankings = groupRankings[group.groupId] || [];
if (rankings.length === 0) return;
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = `Gruppe ${group.groupNumber}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Matrix erstellen
const head = [['', ...rankings.map((p, idx) => `G${String.fromCharCode(96 + group.groupNumber)}${idx + 1}`)]];
const body = rankings.map((player, idx) => {
const row = [`G${String.fromCharCode(96 + group.groupNumber)}${idx + 1} ${(player.seeded ? '★ ' : '') + player.name}`];
rankings.forEach((opponent, oppIdx) => {
if (idx === oppIdx) {
row.push('-');
} else {
// Finde Match-Ergebnis
const match = this.findMatchResult ? this.findMatchResult(player.id, opponent.id, group.groupId) : null;
row.push(match || '-');
}
});
return row;
});
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 8 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 50 }
},
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
});
}
addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches = []) {
// Neue Seite für Spiele
this.addNewPage();
this.cursorY = this.margin;
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Alle Spiele', this.margin, this.cursorY);
this.cursorY += 10;
// Für jede Klasse
Object.entries(matchesByClassAndGroup).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
Object.entries(groups).forEach(([groupId, matches]) => {
if (matches.length === 0) return;
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = `Gruppe ${matches[0].groupNumber || groupId}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Spiele-Tabelle
const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']];
const body = matches.map(m => [
m.groupRound?.toString() || '-',
getPlayerName(m.player1),
getPlayerName(m.player2),
m.isFinished ? (m.result || '-') : '-',
this.formatSets(m)
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 9 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
});
// K.O.-Runden hinzufügen (wenn vorhanden)
if (knockoutMatches && knockoutMatches.length > 0) {
// Gruppiere K.O.-Matches nach Klassen
const knockoutMatchesByClass = {};
knockoutMatches.forEach(match => {
const classKey = match.classId != null ? String(match.classId) : 'null';
if (!knockoutMatchesByClass[classKey]) {
knockoutMatchesByClass[classKey] = [];
}
knockoutMatchesByClass[classKey].push(match);
});
// Sortiere Klassen
const sortedClasses = Object.keys(knockoutMatchesByClass).sort((a, b) => {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
});
// Für jede Klasse
sortedClasses.forEach(classKey => {
const classMatches = knockoutMatchesByClass[classKey];
const className = classKey !== 'null' && classKey !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classKey) : null;
// Sortiere Matches nach Runde (frühere Runden zuerst: Achtelfinale, Viertelfinale, Halbfinale, Finale)
const getRoundType = (roundName) => {
if (!roundName) return 999;
if (roundName.includes('Achtelfinale')) return 0;
if (roundName.includes('Viertelfinale')) return 1;
if (roundName.includes('Halbfinale')) return 2;
if (roundName.includes('Finale')) return 3;
// Für Runden wie "6-Runde", "8-Runde" etc. - extrahiere die Zahl
const numberMatch = roundName.match(/(\d+)-Runde/);
if (numberMatch) {
const num = parseInt(numberMatch[1]);
// Größere Zahlen = frühere Runden, also umgekehrt sortieren
return -num; // Negativ, damit größere Zahlen zuerst kommen
}
return 999; // Unbekannte Runden zuletzt
};
classMatches.sort((a, b) => {
const aRoundType = getRoundType(a.round);
const bRoundType = getRoundType(b.round);
if (aRoundType !== bRoundType) {
return aRoundType - bRoundType;
}
// Wenn gleicher Typ, alphabetisch sortieren
return (a.round || '').localeCompare(b.round || '');
});
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = 'K.-o.-Runde';
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Spiele-Tabelle
const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']];
const body = classMatches.map(m => [
m.round || '-',
getPlayerName(m.player1),
getPlayerName(m.player2),
m.isFinished ? (m.result || '-') : '-',
this.formatSets(m)
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 9 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
}
}
getClassNameForId(classId, groupsByClass) {
// Versuche, den Klassennamen zu finden
// Dies sollte von außen übergeben werden, aber als Fallback:
return `Klasse ${classId}`;
}
findMatchResult(player1Id, player2Id, groupId) {
// Diese Methode sollte von außen übergeben werden
// Für jetzt: Rückgabe von null
return null;
}
formatSets(match) {
if (!match.tournamentResults || match.tournamentResults.length === 0) {
return '-';
}
return match.tournamentResults
.sort((a, b) => a.set - b.set)
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
.join(', ');
}
}
export default PDFGenerator;

View File

@@ -10,6 +10,7 @@ import DiaryView from './views/DiaryView.vue';
import PendingApprovalsView from './views/PendingApprovalsView.vue';
import ScheduleView from './views/ScheduleView.vue';
import TournamentsView from './views/TournamentsView.vue';
import ExternalTournamentsView from './views/ExternalTournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
import ClubSettings from './views/ClubSettings.vue';
import PredefinedActivities from './views/PredefinedActivities.vue';
@@ -33,7 +34,8 @@ const routes = [
{ path: '/diary', component: DiaryView },
{ path: '/pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', component: ScheduleView},
{ path: '/tournaments', component: TournamentsView },
{ path: '/tournaments', component: TournamentsView, props: { allowsExternal: false } },
{ path: '/external-tournaments', component: ExternalTournamentsView },
{ path: '/training-stats', component: TrainingStatsView },
{ path: '/club-settings', component: ClubSettings },
{ path: '/predefined-activities', component: PredefinedActivities },

View File

@@ -0,0 +1,14 @@
<template>
<TournamentsView :allowsExternal="true" />
</template>
<script>
import TournamentsView from './TournamentsView.vue';
export default {
name: 'ExternalTournamentsView',
components: {
TournamentsView
}
};
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="official-tournaments">
<h2>Offizielle Turniere</h2>
<h2>Turnierteilnahmen</h2>
<div v-if="list && list.length" class="list">
<div class="tabs">
<button :class="['tab', topActiveTab==='events' ? 'active' : '']" @click="switchTopTab('events')" title="Gespeicherte Veranstaltungen anzeigen">Veranstaltungen</button>

File diff suppressed because it is too large Load Diff