diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js
index dd5e36f..b216376 100644
--- a/backend/controllers/tournamentController.js
+++ b/backend/controllers/tournamentController.js
@@ -135,6 +135,23 @@ export const getTournament = async (req, res) => {
}
};
+// Update Turnier
+export const updateTournament = async (req, res) => {
+ const { authcode: token } = req.headers;
+ const { clubId, tournamentId } = req.params;
+ const { name, date } = req.body;
+ try {
+ const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date);
+ // Emit Socket-Event
+ emitTournamentChanged(clubId, tournamentId);
+ res.status(200).json(tournament);
+ } catch (error) {
+ console.error('[updateTournament] Error:', error);
+ const status = error.message.includes('existiert bereits') ? 400 : 500;
+ res.status(status).json({ error: error.message });
+ }
+};
+
// 10. Alle Spiele eines Turniers abfragen
export const getTournamentMatches = async (req, res) => {
const { authcode: token } = req.headers;
diff --git a/backend/migrations/add_name_to_tournament.sql b/backend/migrations/add_name_to_tournament.sql
new file mode 100644
index 0000000..9373bfe
--- /dev/null
+++ b/backend/migrations/add_name_to_tournament.sql
@@ -0,0 +1,29 @@
+-- Migration: Add name column to tournament table
+-- Date: 2025-01-13
+-- For MariaDB/MySQL
+
+-- Add name column if it doesn't exist
+-- Check if column exists and add it if not
+SET @dbname = DATABASE();
+SET @tablename = 'tournament';
+SET @columnname = 'name';
+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, '` VARCHAR(255) NOT NULL DEFAULT "" AFTER `id`')
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+-- Update existing tournaments: set name to formatted date if name is empty
+UPDATE `tournament`
+SET `name` = DATE_FORMAT(`date`, '%d.%m.%Y')
+WHERE `name` = '' OR `name` IS NULL;
+
diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js
index 8e81dc3..4fe493f 100644
--- a/backend/routes/tournamentRoutes.js
+++ b/backend/routes/tournamentRoutes.js
@@ -2,6 +2,7 @@ import express from 'express';
import {
getTournaments,
addTournament,
+ updateTournament,
addParticipant,
getParticipants,
setModus,
@@ -39,6 +40,7 @@ router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
+router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);
diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js
index 6f95629..793fb0f 100644
--- a/backend/services/tournamentService.js
+++ b/backend/services/tournamentService.js
@@ -161,17 +161,12 @@ class TournamentService {
throw new Error('Turnier nicht gefunden');
}
- // 1) Hole vorhandene Gruppen
- let groups = await TournamentGroup.findAll({ where: { tournamentId } });
-
- // **Neu**: Falls noch keine Gruppen existieren, lege sie nach numberOfGroups an
- if (!groups.length) {
- const desired = tournament.numberOfGroups || 1; // Fallback auf 1, wenn undefiniert
- for (let i = 0; i < desired; i++) {
- await TournamentGroup.create({ tournamentId });
- }
- groups = await TournamentGroup.findAll({ where: { tournamentId } });
- }
+ // 1) Stelle sicher, dass die richtige Anzahl von Gruppen existiert
+ await this.createGroups(userToken, clubId, tournamentId);
+ let groups = await TournamentGroup.findAll({
+ where: { tournamentId },
+ order: [['id', 'ASC']] // Stelle sicher, dass Gruppen sortiert sind
+ });
const members = await TournamentMember.findAll({ where: { tournamentId } });
if (!members.length) {
@@ -181,32 +176,39 @@ class TournamentService {
// 2) Alte Matches löschen
await TournamentMatch.destroy({ where: { tournamentId } });
- // 3) Prüfe, ob Spieler bereits manuell zugeordnet wurden
- const alreadyAssigned = members.filter(m => m.groupId !== null);
- const unassigned = members.filter(m => m.groupId === null);
-
- if (alreadyAssigned.length > 0) {
- // Spieler sind bereits manuell zugeordnet - nicht neu verteilen
- devLog(`${alreadyAssigned.length} Spieler bereits zugeordnet, ${unassigned.length} noch nicht zugeordnet`);
- } else {
- // Keine manuellen Zuordnungen - zufällig verteilen
- const shuffled = members.slice();
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- groups.forEach((g, idx) => {
- shuffled
- .filter((_, i) => i % groups.length === idx)
- .forEach(m => m.update({ groupId: g.id }));
- });
- }
+ // 3) Alle Zuordnungen löschen und zufällig neu verteilen
+ // (Bei "Zufällig verteilen" sollen alle alten Zuordnungen gelöscht werden)
+ await TournamentMember.update(
+ { groupId: null },
+ { where: { tournamentId } }
+ );
- // 4) Round‑Robin anlegen wie gehabt - NUR innerhalb jeder Gruppe
- for (const g of groups) {
+ // 4) Zufällig verteilen
+ const shuffled = members.slice();
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+
+ // Warte auf alle Updates, damit die Zuordnungen korrekt sind
+ const updatePromises = [];
+ groups.forEach((g, idx) => {
+ const groupMembers = shuffled.filter((_, i) => i % groups.length === idx);
+ groupMembers.forEach(m => {
+ updatePromises.push(m.update({ groupId: g.id }));
+ });
+ });
+ await Promise.all(updatePromises);
+
+ // 5) Round‑Robin anlegen wie gehabt - NUR innerhalb jeder Gruppe
+ // Stelle sicher, dass Gruppen sortiert sind
+ const sortedGroups = groups.sort((a, b) => a.id - b.id);
+
+ for (const g of sortedGroups) {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
if (gm.length < 2) {
+ console.log(`⚠️ Gruppe ${g.id} hat weniger als 2 Spieler (${gm.length}), überspringe Spiele-Erstellung`);
continue;
}
@@ -214,11 +216,10 @@ class TournamentService {
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Id, p2Id] of rounds[roundIndex]) {
- // Prüfe, ob beide Spieler zur gleichen Gruppe gehören
- const p1 = gm.find(p => p.id === p1Id);
- const p2 = gm.find(p => p.id === p2Id);
- if (p1 && p2 && p1.groupId === p2.groupId && p1.groupId === g.id) {
- const match = await TournamentMatch.create({
+ // p1Id und p2Id sind bereits aus gm, also müssen sie zur Gruppe g gehören
+ // Prüfe nur, ob beide IDs vorhanden sind (nicht null, falls Bye)
+ if (p1Id && p2Id) {
+ await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
@@ -283,6 +284,29 @@ class TournamentService {
return t;
}
+ // Update Turnier (Name und Datum)
+ async updateTournament(userToken, clubId, tournamentId, name, date) {
+ await checkAccess(userToken, clubId);
+ const tournament = await Tournament.findOne({ where: { id: tournamentId, clubId } });
+ if (!tournament) {
+ throw new Error('Turnier nicht gefunden');
+ }
+
+ // Prüfe auf Duplikat, wenn Datum geändert wird
+ if (date && date !== tournament.date) {
+ const existing = await Tournament.findOne({ where: { clubId, date, id: { [Op.ne]: tournamentId } } });
+ if (existing) {
+ throw new Error('Ein Turnier mit diesem Datum existiert bereits');
+ }
+ }
+
+ if (name !== undefined) tournament.name = name;
+ if (date !== undefined) tournament.date = date;
+
+ await tournament.save();
+ return JSON.parse(JSON.stringify(tournament));
+ }
+
// 11. Spiele eines Turniers
async getTournamentMatches(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
@@ -296,8 +320,8 @@ class TournamentService {
{ model: TournamentResult, as: 'tournamentResults' }
],
order: [
- ['group_id', 'ASC'],
- ['group_round', 'ASC'],
+ ['group_round', 'ASC'], // Zuerst nach Runde sortieren
+ // group_id wird nicht sortiert, da die logische groupNumber im Frontend verwendet wird
['id', 'ASC'],
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
]
diff --git a/frontend/src/views/TournamentsView.vue b/frontend/src/views/TournamentsView.vue
index 254691a..5946382 100644
--- a/frontend/src/views/TournamentsView.vue
+++ b/frontend/src/views/TournamentsView.vue
@@ -6,19 +6,45 @@
-
+
+
-
- -
- {{ participant.member?.firstName || 'Unbekannt' }}
- {{ participant.member?.lastName || '' }}
+
+ -
+
+ {{ participant.member?.firstName || 'Unbekannt' }}
+ {{ participant.member?.lastName || '' }}
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
@@ -698,6 +702,9 @@ export default {
},
selectedDate: 'new',
newDate: '',
+ newTournamentName: '',
+ currentTournamentName: '',
+ currentTournamentDate: '',
dates: [],
participants: [],
selectedMember: null,
@@ -756,16 +763,55 @@ export default {
},
groupMatches() {
- return this.matches
- .filter(m => m.round === 'group')
- .sort((a, b) => {
- // zuerst nach Runde
- if (a.groupRound !== b.groupRound) {
- return a.groupRound - b.groupRound;
+ const filtered = this.matches.filter(m => m.round === 'group');
+
+ // Gruppiere nach Runde
+ const byRound = {};
+ filtered.forEach(m => {
+ const round = m.groupRound || 0;
+ if (!byRound[round]) {
+ byRound[round] = [];
+ }
+ byRound[round].push(m);
+ });
+
+ // Sortiere die Runden
+ const sortedRounds = Object.keys(byRound).sort((a, b) => Number(a) - Number(b));
+
+ // Für jede Runde: Sortiere nach Gruppe, dann interleaved zusammenführen
+ const result = [];
+ sortedRounds.forEach(round => {
+ const matchesInRound = byRound[round];
+
+ // Gruppiere nach groupNumber
+ const byGroup = {};
+ matchesInRound.forEach(m => {
+ const groupNum = m.groupNumber || 0;
+ if (!byGroup[groupNum]) {
+ byGroup[groupNum] = [];
}
- // dann nach Gruppe
- return a.groupNumber - b.groupNumber;
+ byGroup[groupNum].push(m);
});
+
+ // Sortiere die Gruppen
+ const sortedGroups = Object.keys(byGroup).sort((a, b) => Number(a) - Number(b));
+
+ // Interleaved: Nimm abwechselnd aus jeder Gruppe
+ let maxLength = 0;
+ sortedGroups.forEach(groupNum => {
+ maxLength = Math.max(maxLength, byGroup[groupNum].length);
+ });
+
+ for (let i = 0; i < maxLength; i++) {
+ sortedGroups.forEach(groupNum => {
+ if (i < byGroup[groupNum].length) {
+ result.push(byGroup[groupNum][i]);
+ }
+ });
+ }
+ });
+
+ return result;
},
groupRankings() {
@@ -1011,9 +1057,14 @@ export default {
`/tournament/${this.currentClub}/${this.selectedDate}`
);
const tournament = tRes.data;
+ this.currentTournamentName = tournament.name || '';
+ this.currentTournamentDate = tournament.date || '';
this.isGroupTournament = tournament.type === 'groups';
this.numberOfGroups = tournament.numberOfGroups;
this.advancingPerGroup = tournament.advancingPerGroup;
+
+ // Prüfe, ob es einen Trainingstag für das Turnierdatum gibt
+ await this.checkTrainingForDate(tournament.date);
const pRes = await apiClient.post('/tournament/participants', {
clubId: this.currentClub,
tournamentId: this.selectedDate
@@ -1035,15 +1086,19 @@ export default {
}, {});
this.matches = mRes.data.map(m => {
- // Bestimme groupId basierend auf den Spielern, da die Matches groupId: null haben
- const player1GroupId = m.player1?.groupId;
- const player2GroupId = m.player2?.groupId;
- const matchGroupId = player1GroupId || player2GroupId;
+ // Verwende groupId aus dem Backend, falls vorhanden, sonst aus den Spielern
+ const matchGroupId = m.groupId || m.player1?.groupId || m.player2?.groupId;
+
+ // Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen)
+ const groupRound = m.groupRound || m.group_round || 0;
+
+ const groupNumber = grpMap[matchGroupId] || 0;
return {
...m,
- groupId: matchGroupId, // Überschreibe null mit der korrekten groupId
- groupNumber: grpMap[matchGroupId] || 0,
+ groupId: matchGroupId,
+ groupNumber: groupNumber,
+ groupRound: groupRound, // Stelle sicher, dass groupRound gesetzt ist
resultInput: ''
};
});
@@ -1123,24 +1178,30 @@ export default {
}
}
- // Prüfe, ob es einen Trainingstag heute gibt
- await this.checkTrainingToday();
+ // Prüfe nicht hier, da kein Turnier ausgewählt ist
} catch (error) {
console.error('Fehler beim Laden der Turniere:', error);
this.dates = [];
}
},
- async checkTrainingToday() {
+ async checkTrainingForDate(date) {
try {
- const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
+ if (!date) {
+ this.hasTrainingToday = false;
+ return;
+ }
+ // Konvertiere das Datum ins YYYY-MM-DD Format, falls es noch nicht in diesem Format ist
+ const dateStr = date instanceof Date
+ ? date.toISOString().split('T')[0]
+ : date.split('T')[0]; // Falls es ein ISO-String ist, nimm nur den Datumsteil
+
const response = await apiClient.get(`/diary/${this.currentClub}`);
-
- // Die API gibt alle Trainingstage zurück, filtere nach heute
+ // Die API gibt alle Trainingstage zurück, filtere nach dem Turnierdatum
const trainingData = response.data;
if (Array.isArray(trainingData)) {
- this.hasTrainingToday = trainingData.some(training => training.date === today);
+ this.hasTrainingToday = trainingData.some(training => training.date === dateStr);
} else {
this.hasTrainingToday = false;
}
@@ -1150,11 +1211,38 @@ export default {
}
},
+ async updateTournament() {
+ if (!this.currentTournamentDate) {
+ await this.showInfo('Fehler', 'Bitte geben Sie ein Datum ein!', '', 'error');
+ return;
+ }
+ try {
+ await apiClient.put(`/tournament/${this.currentClub}/${this.selectedDate}`, {
+ name: this.currentTournamentName || this.currentTournamentDate,
+ date: this.currentTournamentDate
+ });
+ // Prüfe, ob es einen Trainingstag für das neue Datum gibt
+ await this.checkTrainingForDate(this.currentTournamentDate);
+ // Lade Turnierliste neu, damit der Name in der Dropdown-Liste aktualisiert wird
+ await this.loadTournaments();
+ } catch (error) {
+ console.error('Fehler beim Aktualisieren des Turniers:', error);
+ const message = safeErrorMessage(error, 'Fehler beim Aktualisieren des Turniers.');
+ await this.showInfo('Fehler', message, '', 'error');
+ // Lade Daten neu, um die ursprünglichen Werte wiederherzustellen
+ await this.loadTournamentData();
+ }
+ },
+
async createTournament() {
+ if (!this.newDate) {
+ await this.showInfo('Fehler', 'Bitte geben Sie ein Datum ein!', '', 'error');
+ return;
+ }
try {
const r = await apiClient.post('/tournament', {
clubId: this.currentClub,
- tournamentName: this.newDate,
+ tournamentName: this.newTournamentName || this.newDate,
date: this.newDate
});
@@ -1169,6 +1257,7 @@ export default {
this.selectedDate = newTournamentId;
this.newDate = '';
+ this.newTournamentName = '';
} catch (error) {
console.error('Fehler beim Erstellen des Turniers:', error);
const message = safeErrorMessage(error, 'Fehler beim Erstellen des Turniers.');
@@ -1637,21 +1726,29 @@ export default {
async loadParticipantsFromTraining() {
try {
- const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
+ if (!this.currentTournamentDate) {
+ await this.showInfo('Hinweis', 'Kein Turnierdatum vorhanden!', '', 'info');
+ return;
+ }
+
+ // Konvertiere das Turnierdatum ins YYYY-MM-DD Format
+ const tournamentDate = this.currentTournamentDate instanceof Date
+ ? this.currentTournamentDate.toISOString().split('T')[0]
+ : this.currentTournamentDate.split('T')[0]; // Falls es ein ISO-String ist, nimm nur den Datumsteil
+
const response = await apiClient.get(`/diary/${this.currentClub}`);
-
- // Die API gibt alle Trainingstage zurück, filtere nach heute
+ // Die API gibt alle Trainingstage zurück, filtere nach dem Turnierdatum
const trainingData = response.data;
if (Array.isArray(trainingData)) {
- // Finde den Trainingstag für heute
- const todayTraining = trainingData.find(training => training.date === today);
+ // Finde den Trainingstag für das Turnierdatum
+ const trainingForDate = trainingData.find(training => training.date === tournamentDate);
- if (todayTraining) {
+ if (trainingForDate) {
// Lade die Teilnehmer für diesen Trainingstag über die Participant-API
- const participantsResponse = await apiClient.get(`/participants/${todayTraining.id}`);
+ const participantsResponse = await apiClient.get(`/participants/${trainingForDate.id}`);
const participants = participantsResponse.data;
@@ -1684,16 +1781,16 @@ export default {
// Lade Turnierdaten neu
await this.loadTournamentData();
} else {
- await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
+ await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info');
}
} else {
- await this.showInfo('Hinweis', 'Keine Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
+ await this.showInfo('Hinweis', 'Keine Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info');
}
} else {
- await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
+ await this.showInfo('Hinweis', `Kein Trainingstag für ${tournamentDate} gefunden!`, '', 'info');
}
} else {
- await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
+ await this.showInfo('Hinweis', `Kein Trainingstag für ${tournamentDate} gefunden!`, '', 'info');
}
} catch (error) {
console.error('Fehler beim Laden der Trainingsteilnehmer:', error);
@@ -1852,6 +1949,58 @@ export default {
padding: 1rem;
}
+.new-tournament {
+ margin-top: 1rem;
+ display: flex;
+ gap: 1rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.new-tournament label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.new-tournament input[type="text"],
+.new-tournament input[type="date"] {
+ padding: 0.4rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 1em;
+}
+
+.new-tournament input[type="text"] {
+ min-width: 200px;
+}
+
+.tournament-info {
+ margin-bottom: 1rem;
+ display: flex;
+ gap: 1rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.tournament-info label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.tournament-info input[type="text"],
+.tournament-info input[type="date"] {
+ padding: 0.4rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 1em;
+}
+
+.tournament-info input[type="text"] {
+ min-width: 200px;
+}
+
.participants,
.group-controls,
.groups-overview,
@@ -1999,6 +2148,57 @@ button {
}
}
+.participants-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ width: auto;
+ display: table;
+}
+
+.participant-item {
+ display: table-row;
+ font-size: 0.9em;
+ line-height: 1.1;
+}
+
+.participant-name {
+ display: table-cell;
+ white-space: nowrap;
+}
+
+.participant-group-cell {
+ display: table-cell;
+ white-space: nowrap;
+}
+
+.participant-action-cell {
+ display: table-cell;
+ white-space: nowrap;
+}
+
+.group-select-small {
+ font-size: 0.85em;
+ padding: 2px 4px;
+ width: 50px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+}
+
+.trash-btn-small {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 0.9em;
+ padding: 0;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+}
+
+.trash-btn-small:hover {
+ opacity: 1;
+}
+
.add-participant {
margin-top: 1rem;
padding-top: 1rem;
@@ -2009,6 +2209,29 @@ button {
flex-wrap: wrap;
}
+.member-select {
+ flex: 1;
+ min-width: 200px;
+ padding: 0.4rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+.btn-add {
+ padding: 0.4rem 0.8rem;
+ background-color: #007bff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9em;
+ white-space: nowrap;
+}
+
+.btn-add:hover {
+ background-color: #0056b3;
+}
+
.training-btn {
background-color: #28a745;
color: white;