Implement tournament pairing functionality and enhance participant management

- Introduced new endpoints for managing tournament pairings, including creating, updating, and deleting pairings.
- Updated the tournament service to handle pairing logic, ensuring validation for participants and preventing duplicate pairings.
- Enhanced participant management by adding class-based checks for gender and age restrictions when adding participants.
- Updated the tournament controller and routes to support the new pairing features and improved participant handling.
- Added localization support for new UI elements related to pairings in the frontend, enhancing user experience.
This commit is contained in:
Torsten Schulz (local)
2025-11-29 00:15:01 +01:00
parent bdbbb88be9
commit dc2c60cefe
23 changed files with 4613 additions and 1100 deletions

View File

@@ -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 });
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
<template>
<section class="tournament-classes">
<h4>{{ $t('tournaments.classes') }}</h4>
<div class="classes-content">
<div v-if="tournamentClasses.length === 0" class="no-classes-message">
<p>{{ $t('tournaments.noClassesYet') }}</p>
</div>
<div v-else class="classes-list">
<div v-for="classItem in tournamentClasses" :key="classItem.id" class="class-item">
<template v-if="editingClassId === classItem.id">
<!-- Bearbeitungsfelder werden weiter unten verwendet -->
</template>
<template v-else>
<span class="class-name-label">
{{ classItem.name }}
<span class="class-type-inline" :class="{ 'doubles': classItem.isDoubles }">
({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
</span>
</span>
<span v-if="classItem.gender" class="class-gender-badge" :class="'gender-' + classItem.gender">
{{ classItem.gender === 'male' ? $t('members.genderMale') : classItem.gender === 'female' ? $t('members.genderFemale') : $t('tournaments.genderMixed') }}
</span>
<span v-if="classItem.minBirthYear" class="class-birth-year-badge">
{{ classItem.minBirthYear }}
</span>
<button @click.stop="editClass(classItem)" class="btn-edit-small" :title="$t('tournaments.edit')"></button>
<button @click.stop="deleteClass(classItem)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑</button>
</template>
</div>
</div>
<!-- Gemeinsame Eingabefelder für Hinzufügen und Bearbeiten -->
<div class="add-class" :class="{ 'editing': editingClassId !== null }">
<input
type="text"
:value="isEditing ? localEditingClassName : localNewClassName"
@input="handleNameInput($event)"
:placeholder="$t('tournaments.className')"
class="class-name-input"
@keyup.enter="isEditing ? saveClassEdit(getEditingClassItem()) : addClass()"
@keyup.esc="isEditing ? cancelClassEdit() : null"
ref="classInput"
/>
<label class="class-doubles-checkbox" @mousedown.prevent>
<input
type="checkbox"
:checked="isEditing ? localEditingClassIsDoubles : localNewClassIsDoubles"
@change="handleDoublesChange($event)"
@mousedown.stop
/>
<span>{{ $t('tournaments.doubles') }}</span>
</label>
<select
:value="isEditing ? localEditingClassGender : localNewClassGender"
@change="handleGenderChange($event)"
class="class-gender-select"
@mousedown.stop
>
<option :value="null">{{ $t('tournaments.genderAll') }}</option>
<option value="male">{{ $t('members.genderMale') }}</option>
<option value="female">{{ $t('members.genderFemale') }}</option>
<option value="mixed">{{ $t('tournaments.genderMixed') }}</option>
</select>
<input
type="number"
:value="isEditing ? localEditingClassMaxBirthYear : localNewClassMaxBirthYear"
@input="handleMaxBirthYearInput($event)"
:min="currentYear - 18"
:max="currentYear - 6"
:placeholder="$t('tournaments.minBirthYear')"
class="class-birth-year-input"
@mousedown.stop
/>
<button
v-if="isEditing"
@click="saveClassEdit(getEditingClassItem())"
class="btn-save-small"
:title="$t('tournaments.save')"
></button>
<button
v-if="isEditing"
@click="cancelClassEdit"
class="btn-cancel-small"
:title="$t('tournaments.cancel')"
></button>
<button
v-else
@click="addClass"
class="btn-add"
>{{ $t('tournaments.addClass') }}</button>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'TournamentClassList',
props: {
tournamentClasses: {
type: Array,
required: true
},
showClasses: {
type: Boolean,
default: false
},
editingClassId: {
type: [Number, null],
default: null
},
editingClassName: {
type: String,
default: ''
},
editingClassIsDoubles: {
type: Boolean,
default: false
},
editingClassGender: {
type: [String, null],
default: null
},
editingClassMaxBirthYear: {
type: [Number, null],
default: null
},
newClassName: {
type: String,
default: ''
},
newClassIsDoubles: {
type: Boolean,
default: false
},
newClassGender: {
type: [String, null],
default: null
},
newClassMaxBirthYear: {
type: [Number, null],
default: null
}
},
emits: [
'edit-class',
'save-class-edit',
'cancel-class-edit',
'delete-class',
'add-class',
'add-class-error',
'update:newClassName',
'update:newClassIsDoubles',
'update:newClassGender',
'update:editingClassName',
'update:editingClassIsDoubles',
'update:editingClassGender',
'update:editingClassMaxBirthYear',
'update:newClassMaxBirthYear',
'handle-class-input-blur'
],
data() {
return {
localEditingClassName: '',
localEditingClassIsDoubles: false,
localEditingClassGender: null,
localEditingClassMaxBirthYear: null,
localNewClassName: '',
localNewClassIsDoubles: false,
localNewClassGender: null,
localNewClassMaxBirthYear: null
};
},
watch: {
editingClassName(newVal) {
this.localEditingClassName = newVal;
},
editingClassIsDoubles(newVal) {
this.localEditingClassIsDoubles = newVal;
},
editingClassGender(newVal) {
this.localEditingClassGender = newVal;
},
editingClassMaxBirthYear(newVal) {
this.localEditingClassMaxBirthYear = newVal;
},
newClassName(newVal) {
this.localNewClassName = newVal;
},
newClassIsDoubles(newVal) {
this.localNewClassIsDoubles = newVal;
},
newClassGender(newVal) {
this.localNewClassGender = newVal;
},
newClassMaxBirthYear(newVal) {
this.localNewClassMaxBirthYear = newVal;
},
editingClassId(newVal) {
// Wenn eine neue Klasse zum Bearbeiten ausgewählt wird, aktualisiere die lokalen Werte
if (newVal !== null) {
const classItem = this.tournamentClasses.find(c => c.id === newVal);
if (classItem) {
this.localEditingClassName = classItem.name;
this.localEditingClassIsDoubles = Boolean(classItem.isDoubles);
this.localEditingClassGender = classItem.gender || null;
this.localEditingClassMaxBirthYear = classItem.minBirthYear || null;
}
// Bearbeitungsmodus: Fokussiere das Eingabefeld
this.$nextTick(() => {
if (this.$refs.classInput) {
this.$refs.classInput.focus();
this.$refs.classInput.select();
}
});
} else {
// Wenn Bearbeitung abgebrochen wird, setze die Werte zurück
this.localEditingClassName = '';
this.localEditingClassIsDoubles = false;
this.localEditingClassGender = null;
this.localEditingClassMaxBirthYear = null;
// Felder für Hinzufügen zurücksetzen
this.localNewClassName = '';
this.localNewClassIsDoubles = false;
this.localNewClassGender = null;
this.localNewClassMaxBirthYear = null;
}
}
},
computed: {
currentYear() {
return new Date().getFullYear();
},
isEditing() {
return this.editingClassId !== null;
}
},
mounted() {
// Initialisiere lokale Werte aus Props
this.localEditingClassName = this.editingClassName;
this.localEditingClassIsDoubles = this.editingClassIsDoubles;
this.localEditingClassGender = this.editingClassGender;
this.localEditingClassMaxBirthYear = this.editingClassMaxBirthYear;
this.localNewClassName = this.newClassName;
this.localNewClassIsDoubles = this.newClassIsDoubles;
this.localNewClassGender = this.newClassGender;
this.localNewClassMaxBirthYear = this.newClassMaxBirthYear;
},
methods: {
editClass(classItem) {
this.$emit('edit-class', classItem);
},
saveClassEdit(classItem) {
this.$emit('save-class-edit', classItem);
},
cancelClassEdit() {
this.$emit('cancel-class-edit');
},
deleteClass(classItem) {
this.$emit('delete-class', classItem);
},
addClass() {
// Validiere zuerst in der Komponente
const className = this.localNewClassName;
if (!className || !className.trim()) {
// Emittiere ein Error-Event, damit der Parent die Fehlermeldung anzeigen kann
this.$emit('add-class-error', 'Bitte geben Sie einen Klassennamen ein!');
return;
}
// Stelle sicher, dass die Werte synchronisiert sind, bevor das Event emittiert wird
this.$emit('update:newClassName', this.localNewClassName);
this.$emit('update:newClassIsDoubles', this.localNewClassIsDoubles);
this.$emit('update:newClassGender', this.localNewClassGender);
this.$emit('update:newClassMaxBirthYear', this.localNewClassMaxBirthYear);
const data = {
name: this.localNewClassName,
isDoubles: this.localNewClassIsDoubles,
gender: this.localNewClassGender,
minBirthYear: this.localNewClassMaxBirthYear
};
// Emittiere das Event mit den aktuellen Werten als Parameter
this.$emit('add-class', data);
// Felder zurücksetzen nach dem Hinzufügen
this.localNewClassName = '';
this.localNewClassIsDoubles = false;
this.localNewClassGender = null;
this.localNewClassMaxBirthYear = null;
},
getEditingClassItem() {
return this.tournamentClasses.find(c => c.id === this.editingClassId);
},
handleNameInput(event) {
const value = event.target.value;
if (this.isEditing) {
this.localEditingClassName = value;
this.$emit('update:editingClassName', value);
} else {
this.localNewClassName = value;
this.$emit('update:newClassName', value);
}
},
handleDoublesChange(event) {
const checked = event.target.checked;
if (this.isEditing) {
this.localEditingClassIsDoubles = checked;
this.$emit('update:editingClassIsDoubles', checked);
} else {
this.localNewClassIsDoubles = checked;
this.$emit('update:newClassIsDoubles', checked);
}
},
handleGenderChange(event) {
const value = event.target.value === 'null' ? null : event.target.value;
if (this.isEditing) {
this.localEditingClassGender = value;
this.$emit('update:editingClassGender', value);
} else {
this.localNewClassGender = value;
this.$emit('update:newClassGender', value);
}
},
handleMaxBirthYearInput(event) {
const value = event.target.value ? parseInt(event.target.value) : null;
if (this.isEditing) {
this.localEditingClassMaxBirthYear = value;
this.$emit('update:editingClassMaxBirthYear', value);
} else {
this.localNewClassMaxBirthYear = value;
this.$emit('update:newClassMaxBirthYear', value);
}
},
handleClassInputBlur(event, classItem) {
this.$emit('handle-class-input-blur', event, classItem);
}
}
};
</script>
<style scoped>
.class-type-inline {
color: #4caf50;
font-size: 0.75em;
margin-left: 0.3rem;
font-weight: 500;
}
.class-birth-year-input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9em;
min-width: 120px;
background-color: white;
}
.class-birth-year-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85em;
margin-left: 0.5rem;
background-color: #e3f2fd;
color: #1976d2;
font-weight: 500;
}
.add-class.editing {
background-color: #fff3cd;
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ffc107;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<section v-if="selectedDate && selectedDate !== 'new'" class="class-selection-section">
<div class="participants-class-filter">
<label>
{{ $t('tournaments.showClass') }}:
<select :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" class="class-filter-select">
<option :value="null">{{ $t('tournaments.allClasses') }}</option>
<option v-for="classItem in tournamentClasses" :key="classItem.id" :value="classItem.id">
{{ classItem.name }} ({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
</option>
<option value="__none__">{{ $t('tournaments.withoutClass') }}</option>
</select>
</label>
</div>
</section>
</template>
<script>
export default {
name: 'TournamentClassSelector',
props: {
modelValue: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
selectedDate: {
type: [String, Number],
default: null
}
},
emits: ['update:modelValue']
};
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div class="tab-content" key="config-tab">
<div class="tournament-info">
<label>
{{ $t('tournaments.name') }}:
<input type="text" :value="tournamentName" @input="$emit('update:tournamentName', $event.target.value)" />
</label>
<label>
{{ $t('tournaments.date') }}:
<input type="date" :value="tournamentDate" @change="$emit('update:tournamentDate', $event.target.value)" />
</label>
<label>
{{ $t('tournaments.winningSets') }}:
<input type="number" :value="winningSets" @input="$emit('update:winningSets', parseInt($event.target.value))" min="1" />
</label>
<button @click="$emit('generate-pdf')" class="btn-primary" style="margin-top: 1rem;">{{ $t('tournaments.exportPDF') }}</button>
</div>
<label class="checkbox-item">
<input type="checkbox" :checked="isGroupTournament" @change="$emit('update:isGroupTournament', $event.target.checked)" />
<span>{{ $t('tournaments.playInGroups') }}</span>
</label>
<TournamentClassList
:tournament-classes="tournamentClasses"
:show-classes="showClasses"
:editing-class-id="editingClassId"
:editing-class-name="editingClassName"
:editing-class-is-doubles="editingClassIsDoubles"
:editing-class-gender="editingClassGender"
:editing-class-min-birth-year="editingClassMinBirthYear"
:new-class-name="newClassName"
:new-class-is-doubles="newClassIsDoubles"
:new-class-gender="newClassGender"
:new-class-min-birth-year="newClassMinBirthYear"
@edit-class="$emit('edit-class', $event)"
@save-class-edit="$emit('save-class-edit', $event)"
@cancel-class-edit="$emit('cancel-class-edit')"
@delete-class="$emit('delete-class', $event)"
@add-class="(data) => $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)"
/>
</div>
</template>
<script>
import TournamentClassList from './TournamentClassList.vue';
export default {
name: 'TournamentConfigTab',
components: {
TournamentClassList
},
props: {
tournamentName: {
type: String,
required: true
},
tournamentDate: {
type: String,
required: true
},
winningSets: {
type: Number,
required: true
},
isGroupTournament: {
type: Boolean,
required: true
},
tournamentClasses: {
type: Array,
required: true
},
showClasses: {
type: Boolean,
default: false
},
editingClassId: {
type: [Number, null],
default: null
},
editingClassName: {
type: String,
default: ''
},
editingClassIsDoubles: {
type: Boolean,
default: false
},
editingClassGender: {
type: [String, null],
default: null
},
editingClassMinBirthYear: {
type: [Number, null],
default: null
},
newClassName: {
type: String,
default: ''
},
newClassIsDoubles: {
type: Boolean,
default: false
},
newClassGender: {
type: [String, null],
default: null
},
newClassMinBirthYear: {
type: [Number, null],
default: null
}
},
emits: [
'update:tournamentName',
'update:tournamentDate',
'update:winningSets',
'update:isGroupTournament',
'generate-pdf',
'edit-class',
'save-class-edit',
'cancel-class-edit',
'delete-class',
'add-class',
'add-class-error',
'handle-class-input-blur',
'update:editingClassName',
'update:editingClassIsDoubles',
'update:editingClassGender',
'update:editingClassMinBirthYear',
'update:newClassName',
'update:newClassIsDoubles',
'update:newClassGender',
'update:newClassMinBirthYear'
]
};
</script>

View File

@@ -0,0 +1,349 @@
<template>
<div class="tab-content">
<TournamentClassSelector
v-if="selectedDate && selectedDate !== 'new'"
:model-value="selectedViewClass"
:tournament-classes="tournamentClasses"
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section v-if="isGroupTournament" class="group-controls">
<label>
{{ $t('tournaments.advancersPerGroup') }}:
<input type="number" :value="advancingPerGroup" @input="$emit('update:advancingPerGroup', parseInt($event.target.value))" min="1" @change="$emit('modus-change')" />
</label>
<label style="margin-left:1em">
{{ $t('tournaments.maxGroupSize') }}:
<input type="number" :value="maxGroupSize" @input="$emit('update:maxGroupSize', parseInt($event.target.value))" min="1" />
</label>
<div v-if="selectedViewClass !== null && selectedViewClass !== undefined" class="groups-per-class">
<h4>{{ $t('tournaments.groupsPerClass') }}</h4>
<p class="groups-per-class-hint">{{ $t('tournaments.groupsPerClassHint') }}</p>
<div class="class-group-config">
<label class="class-group-label">
<span class="class-group-name">
{{ selectedViewClass === '__none__' ? $t('tournaments.withoutClass') : getClassName(selectedViewClass) }}
</span>
<span v-if="selectedViewClass !== '__none__'" class="class-group-type" :class="{ 'doubles': isClassDoubles(selectedViewClass) }">
({{ isClassDoubles(selectedViewClass) ? $t('tournaments.doubles') : $t('tournaments.singles') }})
</span>
<input
type="number"
:value="groupsPerClassInput"
@input="$emit('update:groupsPerClassInput', parseInt($event.target.value))"
min="0"
@change="$emit('group-count-change')"
class="class-group-input"
:placeholder="$t('tournaments.numberOfGroups')"
/>
<span class="class-group-unit">{{ $t('tournaments.group') }}</span>
</label>
</div>
</div>
<div v-else class="groups-per-class">
<label>
{{ $t('tournaments.numberOfGroups') }}:
<input type="number" :value="numberOfGroups" @input="$emit('update:numberOfGroups', parseInt($event.target.value))" min="1" @change="$emit('group-count-change')" />
</label>
</div>
<button @click="$emit('create-groups')">{{ $t('tournaments.createGroups') }}</button>
<button @click="$emit('randomize-groups')">{{ $t('tournaments.randomizeGroups') }}</button>
<button @click="$emit('reset-groups')">{{ $t('tournaments.resetGroups') }}</button>
</section>
<section v-if="groups.length" class="groups-overview">
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
<template v-for="(classGroups, classId) in groupsByClass" :key="classId">
<template v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))">
<div v-if="classId !== 'null' && classId !== 'undefined'" class="class-section">
<h4 class="class-header">
{{ getClassName(classId) }}
</h4>
</div>
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
<h4>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h4>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.index') }}</th>
<th>{{ $t('tournaments.position') }}</th>
<th>{{ $t('tournaments.player') }}</th>
<th>{{ $t('tournaments.points') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
<th>{{ $t('tournaments.diff') }}</th>
<th>{{ $t('tournaments.pointsRatio') }}</th>
<th v-for="(opponent, idx) in groupRankings[group.groupId]" :key="`opp-${opponent.id}`">
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
</th>
<th>{{ $t('tournaments.livePosition') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(pl, idx) in groupRankings[group.groupId]" :key="pl.id">
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
<td>{{ pl.position }}.</td>
<td><span v-if="pl.seeded" class="seeded-star"></span>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
</td>
<td>
{{ pl.pointsWon }}:{{ pl.pointsLost }} ({{ (pl.pointsWon - pl.pointsLost) >= 0 ? '+' + (pl.pointsWon - pl.pointsLost) : (pl.pointsWon - pl.pointsLost) }})
</td>
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
:key="`match-${pl.id}-${opponent.id}`"
:class="['match-cell', { 'clickable': idx !== oppIdx, 'active-group-cell': activeGroupCells.includes(`match-${pl.id}-${opponent.id}`), 'diagonal-cell': idx === oppIdx }]"
@click="idx !== oppIdx ? $emit('highlight-match', pl.id, opponent.id, group.groupId) : null">
<span v-if="idx === oppIdx" class="diagonal"></span>
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
:class="getMatchCellClasses(pl.id, opponent.id, group.groupId)">
{{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
</span>
<span v-else class="no-match">-</span>
</td>
<td>{{ getLivePosition(pl.id, group.groupId) }}.</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
<div class="reset-controls" style="margin-top:1rem">
<button @click="$emit('reset-matches')" class="trash-btn">
🗑 {{ $t('tournaments.resetGroupMatches') }}
</button>
</div>
</section>
</div>
</template>
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
export default {
name: 'TournamentGroupsTab',
components: {
TournamentClassSelector
},
props: {
selectedDate: {
type: [String, Number],
default: null
},
selectedViewClass: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
isGroupTournament: {
type: Boolean,
required: true
},
advancingPerGroup: {
type: Number,
required: true
},
maxGroupSize: {
type: Number,
default: null
},
groupsPerClassInput: {
type: Number,
default: 0
},
numberOfGroups: {
type: Number,
required: true
},
groups: {
type: Array,
required: true
},
groupsByClass: {
type: Object,
required: true
},
groupRankings: {
type: Object,
required: true
},
activeGroupCells: {
type: Array,
default: () => []
},
matches: {
type: Array,
required: true
}
},
emits: [
'update:selectedViewClass',
'update:advancingPerGroup',
'update:maxGroupSize',
'update:groupsPerClassInput',
'update:numberOfGroups',
'modus-change',
'group-count-change',
'create-groups',
'randomize-groups',
'reset-groups',
'reset-matches',
'highlight-match'
],
methods: {
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 === 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);
return classItem ? classItem.name : '';
} catch (e) {
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;
},
getMatchLiveResult(player1Id, player2Id, groupId) {
const match = this.matches.find(m =>
m.round === 'group' &&
m.groupId === groupId &&
((m.player1.id === player1Id && m.player2.id === player2Id) ||
(m.player1.id === player2Id && m.player2.id === player1Id))
);
if (!match) return null;
// Berechne aktuelle Sätze aus tournamentResults
let sets1 = 0, sets2 = 0;
if (match.tournamentResults && match.tournamentResults.length > 0) {
match.tournamentResults.forEach(result => {
if (result.pointsPlayer1 > result.pointsPlayer2) {
sets1++;
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
sets2++;
}
});
}
return {
sets1,
sets2,
isFinished: match.isFinished,
player1Won: sets1 > sets2,
player2Won: sets2 > sets1
};
},
getMatchDisplayText(player1Id, player2Id, groupId) {
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
if (!liveResult) return '-';
return `${liveResult.sets1}:${liveResult.sets2}`;
},
getMatchCellClasses(player1Id, player2Id, groupId) {
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
if (!liveResult) return ['no-match'];
const classes = ['match-result'];
if (liveResult.isFinished) {
if (liveResult.player1Won) {
classes.push('match-finished-win');
} else if (liveResult.player2Won) {
classes.push('match-finished-loss');
} else {
classes.push('match-finished-tie');
}
} else {
classes.push('match-live');
}
return classes;
},
getLivePosition(playerId, groupId) {
const groupPlayers = this.groupRankings[groupId] || [];
const liveStats = groupPlayers.map(player => {
let livePoints = player.points || 0;
let liveSetsWon = player.setsWon || 0;
let liveSetsLost = player.setsLost || 0;
const playerMatches = this.matches.filter(m =>
m.round === 'group' &&
m.groupId === groupId &&
(m.player1.id === player.id || m.player2.id === player.id) &&
!m.isFinished &&
m.tournamentResults && m.tournamentResults.length > 0
);
playerMatches.forEach(match => {
const isPlayer1 = match.player1.id === player.id;
match.tournamentResults.forEach(result => {
if (isPlayer1) {
if (result.pointsPlayer1 > result.pointsPlayer2) {
livePoints += 1;
liveSetsWon += 1;
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
liveSetsLost += 1;
}
} else {
if (result.pointsPlayer2 > result.pointsPlayer1) {
livePoints += 1;
liveSetsWon += 1;
} else if (result.pointsPlayer1 > result.pointsPlayer2) {
liveSetsLost += 1;
}
}
});
});
return {
id: player.id,
points: livePoints,
setsWon: liveSetsWon,
setsLost: liveSetsLost,
setDiff: liveSetsWon - liveSetsLost
};
});
liveStats.sort((a, b) => {
if (b.points !== a.points) return b.points - a.points;
if (b.setDiff !== a.setDiff) return b.setDiff - a.setDiff;
return 0;
});
const position = liveStats.findIndex(p => p.id === playerId) + 1;
return position || groupPlayers.findIndex(p => p.id === playerId) + 1;
}
}
};
</script>

View File

@@ -0,0 +1,630 @@
<template>
<div class="tab-content">
<TournamentClassSelector
v-if="selectedDate && selectedDate !== 'new'"
:model-value="selectedViewClass"
:tournament-classes="tournamentClasses"
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section class="participants">
<h4>{{ $t('tournaments.participants') }}</h4>
<div class="participants-content">
<div class="participants-layout">
<div class="add-participant" v-if="canAssignClass">
<div class="add-participant-section">
<h5>{{ $t('tournaments.addClubMember') }}</h5>
<div class="add-participant-row">
<select :value="selectedMember" @change="$emit('update:selectedMember', $event.target.value ? parseInt($event.target.value) : null)" class="member-select">
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
<option v-for="member in filteredClubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="$emit('add-participant')" class="btn-add" :disabled="!selectedMember">{{ $t('tournaments.add') }}</button>
<button v-if="hasTrainingToday && !allowsExternal" @click="$emit('load-participants-from-training')" class="training-btn">
📅 {{ $t('tournaments.loadFromTraining') }}
</button>
</div>
</div>
<div v-if="allowsExternal" class="add-participant-section">
<h5>{{ $t('tournaments.addExternalParticipant') }}</h5>
<div class="add-participant-row">
<input
type="text"
:value="newExternalParticipant.firstName"
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, firstName: $event.target.value })"
:placeholder="$t('tournaments.firstName')"
class="external-input"
/>
<input
type="text"
:value="newExternalParticipant.lastName"
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, lastName: $event.target.value })"
:placeholder="$t('tournaments.lastName')"
class="external-input"
/>
<select
:value="newExternalParticipant.gender"
@change="$emit('update:newExternalParticipant', { ...newExternalParticipant, gender: $event.target.value })"
class="external-input"
>
<option value="unknown">{{ $t('members.genderUnknown') }}</option>
<option value="male">{{ $t('members.genderMale') }}</option>
<option value="female">{{ $t('members.genderFemale') }}</option>
<option value="diverse">{{ $t('members.genderDiverse') }}</option>
</select>
<input
type="text"
:value="newExternalParticipant.club"
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, club: $event.target.value })"
:placeholder="$t('tournaments.club') + ' (' + $t('tournaments.optional') + ')'"
class="external-input"
/>
<input
type="date"
:value="newExternalParticipant.birthDate"
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, birthDate: $event.target.value })"
:placeholder="$t('tournaments.birthdate') + ' (' + $t('tournaments.optional') + ')'"
class="external-input"
/>
<button
@click="$emit('add-external-participant')"
class="btn-add"
:disabled="!newExternalParticipant.firstName || !newExternalParticipant.lastName"
>
{{ $t('tournaments.add') }}
</button>
</div>
</div>
</div>
<div class="participants-table-container">
<!-- Teilnehmer nach Klassen gruppiert - nur angezeigte Klasse -->
<template v-for="classItem in tournamentClasses" :key="classItem.id">
<div v-if="shouldShowClass(classItem.id)" class="participants-class-section">
<h5 class="participants-class-header">
{{ classItem.name }}
<span class="class-type-badge-small" :class="{ 'doubles': classItem.isDoubles }">
({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
</span>
</h5>
<table class="participants-table participants-table-header">
<thead>
<tr>
<th class="participant-seeded-cell">{{ $t('tournaments.seeded') }}</th>
<th class="participant-name">{{ $t('tournaments.name') }}</th>
<th v-if="allowsExternal" class="participant-gender-cell">{{ $t('members.gender') }}</th>
<th v-if="allowsExternal" class="participant-club-cell">{{ $t('tournaments.club') }}</th>
<th v-if="isGroupTournament" class="participant-group-cell">{{ $t('tournaments.group') }}</th>
<th class="participant-action-cell">{{ $t('tournaments.action') }}</th>
</tr>
</thead>
</table>
<div class="participants-table-body-wrapper">
<table class="participants-table participants-table-body">
<tbody>
<tr v-for="participant in getParticipantsForClass(classItem.id)" :key="participant.id" class="participant-item">
<td class="participant-seeded-cell">
<label class="seeded-checkbox-label">
<input type="checkbox" :checked="participant.seeded" @change="$emit('update-participant-seeded', participant, $event)" />
</label>
</td>
<td class="participant-name">
<template v-if="participant.member">
{{ participant.member.firstName || $t('tournaments.unknown') }}
{{ participant.member.lastName || '' }}
</template>
<template v-else>
{{ participant.firstName || $t('tournaments.unknown') }}
{{ participant.lastName || '' }}
</template>
</td>
<td v-if="allowsExternal" class="participant-gender-cell">
<template v-if="participant.member">
<span class="gender-symbol" :class="'gender-' + (participant.member.gender || 'unknown')" :title="labelGender(participant.member.gender)">
{{ genderSymbol(participant.member.gender) }}
</span>
</template>
<template v-else>
<span class="gender-symbol" :class="'gender-' + (participant.gender || 'unknown')" :title="labelGender(participant.gender)">
{{ genderSymbol(participant.gender) }}
</span>
</template>
</td>
<td v-if="allowsExternal" class="participant-club-cell">
<template v-if="participant.member">
<em>{{ $t('tournaments.clubMember') }}</em>
</template>
<template v-else>
{{ participant.club || '' }}
</template>
</td>
<td v-if="isGroupTournament" class="participant-group-cell">
<select
:value="participant.groupNumber"
@change="$emit('update-participant-group', participant, $event)"
class="group-select-small"
>
<option :value="null"></option>
<option v-for="group in getGroupsForClass(classItem.id)" :key="group.groupId" :value="group.groupNumber">
{{ group.groupNumber }}
</option>
</select>
</td>
<td class="participant-action-cell">
<button @click="$emit('remove-participant', participant)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- Teilnehmer ohne Klasse -->
<div v-if="shouldShowClass(null) && getParticipantsForClass(null).length > 0" class="participants-class-section">
<h5 class="participants-class-header">{{ $t('tournaments.withoutClass') }}</h5>
<table class="participants-table participants-table-header">
<thead>
<tr>
<th class="participant-seeded-cell">{{ $t('tournaments.seeded') }}</th>
<th class="participant-name">{{ $t('tournaments.name') }}</th>
<th v-if="allowsExternal" class="participant-gender-cell">{{ $t('members.gender') }}</th>
<th v-if="allowsExternal" class="participant-club-cell">{{ $t('tournaments.club') }}</th>
<th class="participant-class-cell">{{ $t('tournaments.class') }}</th>
<th v-if="isGroupTournament" class="participant-group-cell">{{ $t('tournaments.group') }}</th>
<th class="participant-action-cell">{{ $t('tournaments.action') }}</th>
</tr>
</thead>
</table>
<div class="participants-table-body-wrapper">
<table class="participants-table participants-table-body">
<tbody>
<tr v-for="participant in getParticipantsForClass(null)" :key="participant.id" class="participant-item">
<td class="participant-seeded-cell">
<label class="seeded-checkbox-label">
<input type="checkbox" :checked="participant.seeded" @change="$emit('update-participant-seeded', participant, $event)" />
</label>
</td>
<td class="participant-name">
<template v-if="participant.member">
{{ participant.member.firstName || $t('tournaments.unknown') }}
{{ participant.member.lastName || '' }}
</template>
<template v-else>
{{ participant.firstName || $t('tournaments.unknown') }}
{{ participant.lastName || '' }}
</template>
</td>
<td v-if="allowsExternal" class="participant-gender-cell">
<template v-if="participant.member">
<span class="gender-symbol" :class="'gender-' + (participant.member.gender || 'unknown')" :title="labelGender(participant.member.gender)">
{{ genderSymbol(participant.member.gender) }}
</span>
</template>
<template v-else>
<span class="gender-symbol" :class="'gender-' + (participant.gender || 'unknown')" :title="labelGender(participant.gender)">
{{ genderSymbol(participant.gender) }}
</span>
</template>
</td>
<td v-if="allowsExternal" class="participant-club-cell">
<template v-if="participant.member">
<em>{{ $t('tournaments.clubMember') }}</em>
</template>
<template v-else>
{{ participant.club || '' }}
</template>
</td>
<td class="participant-class-cell">
<select
:value="participant.classId"
@change="$emit('update-participant-class', participant, $event)"
class="class-select-small"
>
<option :value="null"></option>
<option v-for="classItem in tournamentClasses" :key="classItem.id" :value="classItem.id">
{{ classItem.name }}
</option>
</select>
</td>
<td v-if="isGroupTournament" class="participant-group-cell">
<select
:value="participant.groupNumber"
@change="$emit('update-participant-group', participant, $event)"
class="group-select-small"
>
<option :value="null"></option>
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
{{ group.groupNumber }}
</option>
</select>
</td>
<td class="participant-action-cell">
<button @click="$emit('remove-participant', participant)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Doppel-Paarungen -->
<section v-if="selectedViewClass !== null && selectedViewClass !== undefined && selectedViewClass !== '__none__' && isClassDoubles(selectedViewClass)" class="pairings-section">
<div class="pairings-header" @click="$emit('toggle-pairings')">
<h4>{{ $t('tournaments.pairings') }}</h4>
<span class="collapse-icon" :class="{ 'expanded': showPairings }"></span>
</div>
<div v-show="showPairings" class="pairings-content">
<div class="add-pairing">
<h5>{{ $t('tournaments.addPairing') }}</h5>
<div class="pairing-form">
<select
:value="newPairing.player1Id"
@change="$emit('update:newPairing', { ...newPairing, player1Id: $event.target.value ? parseInt($event.target.value) : null })"
class="pairing-player-select"
>
<option :value="null">{{ $t('tournaments.selectPlayer') }} 1</option>
<option v-for="participant in getParticipantsForClass(selectedViewClass)" :key="participant.id" :value="participant.id">
{{ participant.member ? (participant.member.firstName + ' ' + participant.member.lastName) : (participant.firstName + ' ' + participant.lastName) }}
</option>
</select>
<span class="pairing-separator">+</span>
<select
:value="newPairing.player2Id"
@change="$emit('update:newPairing', { ...newPairing, player2Id: $event.target.value ? parseInt($event.target.value) : null })"
class="pairing-player-select"
>
<option :value="null">{{ $t('tournaments.selectPlayer') }} 2</option>
<option v-for="participant in getParticipantsForClass(selectedViewClass)" :key="participant.id" :value="participant.id">
{{ participant.member ? (participant.member.firstName + ' ' + participant.member.lastName) : (participant.firstName + ' ' + participant.lastName) }}
</option>
</select>
<label class="pairing-seeded-label">
<input
type="checkbox"
:checked="newPairing.seeded"
@change="$emit('update:newPairing', { ...newPairing, seeded: $event.target.checked })"
/>
{{ $t('tournaments.seeded') }}
</label>
<button
@click="$emit('add-pairing')"
class="btn-add"
:disabled="!newPairing.player1Id || !newPairing.player2Id || newPairing.player1Id === newPairing.player2Id"
>
{{ $t('tournaments.add') }}
</button>
</div>
<div class="random-pairing-section">
<button @click="$emit('create-random-pairings')" class="btn-random-pairings">{{ $t('tournaments.randomPairings') }}</button>
</div>
</div>
<div class="pairings-list">
<table class="pairings-table">
<thead>
<tr>
<th>{{ $t('tournaments.player') }} 1</th>
<th>{{ $t('tournaments.player') }} 2</th>
<th>{{ $t('tournaments.seeded') }}</th>
<th>{{ $t('tournaments.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="pairing in pairings" :key="pairing.id">
<td>{{ getPairingPlayerName(pairing, 1) }}</td>
<td>{{ getPairingPlayerName(pairing, 2) }}</td>
<td>
<input
type="checkbox"
:checked="pairing.seeded"
@change="$emit('update-pairing-seeded', pairing, $event)"
/>
</td>
<td>
<button @click="$emit('remove-pairing', pairing)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</template>
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
export default {
name: 'TournamentParticipantsTab',
components: {
TournamentClassSelector
},
props: {
selectedDate: {
type: [String, Number],
default: null
},
selectedViewClass: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
showParticipants: {
type: Boolean,
default: false
},
showPairings: {
type: Boolean,
default: false
},
canAssignClass: {
type: Boolean,
required: true
},
allowsExternal: {
type: Boolean,
required: true
},
isGroupTournament: {
type: Boolean,
required: true
},
selectedMember: {
type: [Number, null],
default: null
},
clubMembers: {
type: Array,
required: true
},
hasTrainingToday: {
type: Boolean,
default: false
},
newExternalParticipant: {
type: Object,
required: true
},
participants: {
type: Array,
required: true
},
externalParticipants: {
type: Array,
default: () => []
},
groups: {
type: Array,
required: true
},
pairings: {
type: Array,
required: true
},
newPairing: {
type: Object,
required: true
}
},
emits: [
'update:selectedViewClass',
'update:selectedMember',
'add-participant',
'load-participants-from-training',
'update:newExternalParticipant',
'add-external-participant',
'update-participant-seeded',
'update-participant-group',
'update-participant-class',
'remove-participant',
'toggle-pairings',
'update:newPairing',
'add-pairing',
'create-random-pairings',
'update-pairing-seeded',
'remove-pairing'
],
computed: {
filteredClubMembers() {
// Wenn keine Klasse ausgewählt ist, zeige alle Mitglieder
if (!this.selectedViewClass || this.selectedViewClass === '__none__' || this.selectedViewClass === null) {
return this.clubMembers;
}
// Finde die ausgewählte Klasse
const selectedClassId = Number(this.selectedViewClass);
const selectedClass = this.tournamentClasses.find(c => c.id === selectedClassId);
// Wenn keine Klasse gefunden, zeige alle Mitglieder
if (!selectedClass) {
return this.clubMembers;
}
// Filtere basierend auf Geschlechtsbeschränkung und Geburtsjahr
const classGender = selectedClass.gender;
const minBirthYear = selectedClass.minBirthYear;
return this.clubMembers.filter(member => {
// Filtere nach Geschlecht
const memberGender = member.gender || 'unknown';
let genderMatch = true;
if (classGender) {
// Wenn die Klasse "mixed" ist, erlaube alle Geschlechter
if (classGender === 'mixed') {
genderMatch = true;
} else if (classGender === 'male') {
genderMatch = memberGender === 'male';
} else if (classGender === 'female') {
genderMatch = memberGender === 'female';
}
}
// Filtere nach Geburtsjahr (geboren im Jahr X oder später, also >=)
let birthYearMatch = true;
if (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
birthYearMatch = birthYear >= minBirthYear;
}
}
return genderMatch && birthYearMatch;
});
}
},
methods: {
shouldShowClass(classId) {
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return true;
}
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return classId === null;
}
const selectedId = Number(this.selectedViewClass);
const compareId = Number(classId);
if (Number.isNaN(selectedId) || Number.isNaN(compareId)) {
return false;
}
return selectedId === compareId;
},
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 }));
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;
});
},
getParticipantsForClass(classId) {
return this.allParticipantsList().filter(p => {
if (classId === null || classId === '__none__') {
return p.classId === null || p.classId === undefined;
}
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) => {
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');
});
},
getGroupsForClass(classId) {
return this.groups.filter(g => {
if (classId === null) {
return g.classId === null || g.classId === undefined;
}
return g.classId === classId;
});
},
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;
},
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 '?';
},
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');
}
}
};
</script>
<style scoped>
.participants-layout {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.add-participant {
flex: 0 0 350px;
min-width: 300px;
}
.participants-table-container {
flex: 1;
min-width: 0;
}
@media (max-width: 1024px) {
.participants-layout {
flex-direction: column;
}
.add-participant {
flex: 1;
width: 100%;
}
.participants-table-container {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div class="tab-content">
<TournamentClassSelector
v-if="selectedDate && selectedDate !== 'new'"
:model-value="selectedViewClass"
:tournament-classes="tournamentClasses"
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section v-if="groupMatches.length" class="group-matches">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.round') }}</th>
<th>{{ $t('tournaments.group') }}</th>
<th>{{ $t('tournaments.encounter') }}</th>
<th>{{ $t('tournaments.result') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
<th>{{ $t('tournaments.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<td>{{ m.groupRound }}</td>
<td>
<template v-if="getGroupClassName(m.groupId)">
{{ getGroupClassName(m.groupId) }} - {{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
</template>
<template v-else>
{{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
</template>
</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
<strong>{{ getMatchPlayerNames(m).name1 }}</strong> {{ getMatchPlayerNames(m).name2 }}
</span>
<span v-else>
{{ getMatchPlayerNames(m).name1 }} <strong>{{ getMatchPlayerNames(m).name2 }}</strong>
</span>
</template>
<template v-else>
{{ getMatchPlayerNames(m).name1 }} {{ getMatchPlayerNames(m).name2 }}
</template>
</td>
<td>
<template v-if="!m.isFinished">
<template v-for="r in m.tournamentResults" :key="r.set">
<template v-if="isEditing(m, r.set)">
<input
:value="editingResult.value"
@input="$emit('update:editingResult', { ...editingResult, value: $event.target.value })"
@keyup.enter="$emit('save-edited-result', m)"
@blur="$emit('save-edited-result', m)"
@keyup.escape="$emit('cancel-edit')"
class="inline-input"
ref="editInput"
/>
</template>
<template v-else>
<span @click="$emit('start-edit-result', m, r)" class="result-text clickable">
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
</span>
<span v-if="!isLastResult(m, r)">, </span>
</template>
</template>
<div class="new-set-line">
<input
v-model="m.resultInput"
placeholder="Neuen Satz, z.B. 11:7"
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
@blur="$emit('save-match-result', m, m.resultInput)"
class="inline-input"
/>
</div>
</template>
<template v-else>
{{ formatResult(m) }}
</template>
</td>
<td>
{{ getSetsString(m) }}
</td>
<td>
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Abschließen</button>
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren"></button>
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen"></button>
</td>
</tr>
</tbody>
</table>
</section>
<div v-if="participants.length > 1 && !groupMatches.length && !knockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
<button @click="$emit('start-matches')">
{{ $t('tournaments.createMatches') }}
</button>
</div>
<div v-if="canStartKnockout && !showKnockout && getTotalNumberOfGroups > 1" class="ko-start">
<button @click="$emit('start-knockout')">
{{ $t('tournaments.startKORound') }}
</button>
</div>
<div v-if="showKnockout && canResetKnockout && getTotalNumberOfGroups > 1" class="ko-reset" style="margin-top:1rem">
<button @click="$emit('reset-knockout')" class="trash-btn">
🗑 {{ $t('tournaments.deleteKORound') }}
</button>
</div>
<section v-if="showKnockout && getTotalNumberOfGroups > 1" class="ko-round">
<h4>{{ $t('tournaments.koRound') }}</h4>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.class') }}</th>
<th>{{ $t('tournaments.round') }}</th>
<th>{{ $t('tournaments.encounter') }}</th>
<th>{{ $t('tournaments.result') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
<th>{{ $t('tournaments.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in knockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<td>{{ getKnockoutMatchClassName(m) }}</td>
<td>{{ m.round }}</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
<strong>{{ getMatchPlayerNames(m).name1 }}</strong> {{ getMatchPlayerNames(m).name2 }}
</span>
<span v-else>
{{ getMatchPlayerNames(m).name1 }} <strong>{{ getMatchPlayerNames(m).name2 }}</strong>
</span>
</template>
<template v-else>
{{ getMatchPlayerNames(m).name1 }} {{ getMatchPlayerNames(m).name2 }}
</template>
</td>
<td>
<template v-if="!m.isFinished">
<template v-for="r in m.tournamentResults" :key="r.set">
<template v-if="isEditing(m, r.set)">
<input
:value="editingResult.value"
@input="$emit('update:editingResult', { ...editingResult, value: $event.target.value })"
@keyup.enter="$emit('save-edited-result', m)"
@blur="$emit('save-edited-result', m)"
@keyup.escape="$emit('cancel-edit')"
class="inline-input"
ref="editInput"
/>
</template>
<template v-else>
<span @click="$emit('start-edit-result', m, r)" class="result-text clickable">
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
</span>
<span v-if="!isLastResult(m, r)">, </span>
</template>
</template>
<div class="new-set-line">
<input
v-model="m.resultInput"
placeholder="Neuen Satz, z.B. 11:7"
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
@blur="$emit('save-match-result', m, m.resultInput)"
class="inline-input"
/>
</div>
</template>
<template v-else>
{{ formatResult(m) }}
</template>
</td>
<td>
{{ getSetsString(m) }}
</td>
<td>
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Fertig</button>
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren"></button>
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen"></button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="Object.keys(groupedRankingList).length > 0" class="ranking">
<h4>Rangliste</h4>
<template v-for="(classKey, idx) in Object.keys(groupedRankingList).sort((a, b) => {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
})" :key="`class-${classKey}`">
<div v-if="idx > 0" style="margin-top: 2rem;"></div>
<h5 v-if="getClassName(classKey)">{{ getClassName(classKey) }}</h5>
<table>
<thead>
<tr>
<th>Platz</th>
<th>Spieler</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey]" :key="`${entry.member.id}-${entryIdx}`">
<td>{{ entry.position }}.</td>
<td>
{{ entry.member.firstName }}
{{ entry.member.lastName }}
</td>
</tr>
</tbody>
</table>
</template>
</section>
</div>
</template>
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
export default {
name: 'TournamentResultsTab',
components: {
TournamentClassSelector
},
props: {
selectedDate: {
type: [String, Number],
default: null
},
selectedViewClass: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
groupMatches: {
type: Array,
required: true
},
knockoutMatches: {
type: Array,
required: true
},
activeMatchId: {
type: [Number, null],
default: null
},
editingResult: {
type: Object,
required: true
},
canStartKnockout: {
type: Boolean,
required: true
},
showKnockout: {
type: Boolean,
required: true
},
canResetKnockout: {
type: Boolean,
required: true
},
getTotalNumberOfGroups: {
type: Number,
required: true
},
groupedRankingList: {
type: Object,
required: true
},
participants: {
type: Array,
required: true
},
groups: {
type: Array,
required: true
},
pairings: {
type: Array,
required: true
}
},
emits: [
'update:selectedViewClass',
'update:activeMatchId',
'update:editingResult',
'save-edited-result',
'cancel-edit',
'start-edit-result',
'save-match-result',
'finish-match',
'reopen-match',
'set-match-active',
'start-matches',
'start-knockout',
'reset-knockout'
],
methods: {
getGroupClassName(groupId) {
if (!groupId) return '';
const group = this.groups.find(g => g.groupId === groupId);
if (!group || !group.classId) return '';
return this.getClassName(group.classId);
},
getKnockoutMatchClassName(match) {
if (!match) return '';
if (match.classId) {
return this.getClassName(match.classId);
}
return '';
},
getClassName(classId) {
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);
return classItem ? classItem.name : '';
} catch (e) {
return '';
}
},
getMatchPlayerNames(match) {
const classId = match.classId;
if (classId) {
const tournamentClass = this.tournamentClasses.find(c => c.id === classId);
if (tournamentClass && tournamentClass.isDoubles) {
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 };
}
}
}
return {
name1: this.getPlayerName(match.player1),
name2: this.getPlayerName(match.player2)
};
},
getPlayerName(p) {
if (p.member) {
return p.member.firstName + ' ' + p.member.lastName;
} else {
return (p.firstName || '') + ' ' + (p.lastName || '');
}
},
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');
},
formatResult(match) {
if (!match.tournamentResults?.length) return '-';
return match.tournamentResults
.sort((a, b) => a.set - b.set)
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
.join(', ');
},
getSetsString(match) {
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
for (const r of results) {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
}
return `${win1}:${win2}`;
},
winnerIsPlayer1(match) {
const [w1, w2] = this.getSetsString(match).split(':').map(Number);
return w1 > w2;
},
isEditing(match, set) {
return (
this.editingResult.matchId === match.id &&
this.editingResult.set === set
);
},
isLastResult(match, result) {
const arr = match.tournamentResults || [];
return arr.length > 0 && arr[arr.length - 1].set === result.set;
}
}
};
</script>

View File

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

View File

@@ -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)
});

File diff suppressed because it is too large Load Diff