Add participant assignment to groups functionality

Implement a new endpoint to assign participants to specific groups within tournaments. This includes the addition of the `assignParticipantToGroup` function in the tournament controller, which handles the assignment logic and emits relevant events. Update the tournament routes to include this new functionality. Enhance the tournament service to manage group assignments and ensure proper statistics are calculated for participants. Additionally, update the frontend to support adding participants, including external ones, and reflect changes in the UI for group assignments.
This commit is contained in:
Torsten Schulz (local)
2025-11-23 17:09:41 +01:00
parent f7a799ea7f
commit e6146b8f5a
5 changed files with 472 additions and 210 deletions

View File

@@ -610,6 +610,7 @@
"points": "Punkte",
"sets": "Satz",
"diff": "Diff",
"pointsRatio": "Spielpunkte",
"livePosition": "Live-Platz",
"resetGroupMatches": "Gruppenspiele",
"groupMatches": "Gruppenspiele",

View File

@@ -89,6 +89,47 @@
<span class="collapse-icon" :class="{ 'expanded': showParticipants }"></span>
</div>
<div v-show="showParticipants" class="participants-content">
<div class="add-participant">
<div v-if="allowsExternal" class="add-participant-section">
<h5>{{ $t('tournaments.addClubMember') }}</h5>
<div class="add-participant-row">
<select v-model="selectedMember" class="member-select">
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant" class="btn-add">{{ $t('tournaments.add') }}</button>
<button v-if="hasTrainingToday && !allowsExternal" @click="loadParticipantsFromTraining" 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" v-model="newExternalParticipant.firstName" :placeholder="$t('tournaments.firstName')" class="external-input" />
<input type="text" v-model="newExternalParticipant.lastName" :placeholder="$t('tournaments.lastName')" class="external-input" />
<input type="text" v-model="newExternalParticipant.club" :placeholder="$t('tournaments.club') + ' (' + $t('tournaments.optional') + ')'" class="external-input" />
<input type="date" v-model="newExternalParticipant.birthDate" :placeholder="$t('tournaments.birthdate') + ' (' + $t('tournaments.optional') + ')'" class="external-input" />
<button @click="addExternalParticipant" class="btn-add">{{ $t('tournaments.add') }}</button>
</div>
</div>
<div v-if="!allowsExternal" class="add-participant-row">
<select v-model="selectedMember" class="member-select">
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant" class="btn-add">Hinzufügen</button>
<button v-if="hasTrainingToday" @click="loadParticipantsFromTraining" class="training-btn">
📅 Aus Trainingstag laden
</button>
</div>
</div>
<div class="participants-table-container">
<table class="participants-table participants-table-header">
<thead>
@@ -153,47 +194,6 @@
</table>
</div>
</div>
<div class="add-participant">
<div v-if="allowsExternal" class="add-participant-section">
<h5>{{ $t('tournaments.addClubMember') }}</h5>
<div class="add-participant-row">
<select v-model="selectedMember" class="member-select">
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant" class="btn-add">{{ $t('tournaments.add') }}</button>
<button v-if="hasTrainingToday && !allowsExternal" @click="loadParticipantsFromTraining" 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" v-model="newExternalParticipant.firstName" :placeholder="$t('tournaments.firstName')" class="external-input" />
<input type="text" v-model="newExternalParticipant.lastName" :placeholder="$t('tournaments.lastName')" class="external-input" />
<input type="text" v-model="newExternalParticipant.club" :placeholder="$t('tournaments.club') + ' (' + $t('tournaments.optional') + ')'" class="external-input" />
<input type="date" v-model="newExternalParticipant.birthDate" :placeholder="$t('tournaments.birthdate') + ' (' + $t('tournaments.optional') + ')'" class="external-input" />
<button @click="addExternalParticipant" class="btn-add">{{ $t('tournaments.add') }}</button>
</div>
</div>
<div v-if="!allowsExternal" class="add-participant-row">
<select v-model="selectedMember" class="member-select">
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant" class="btn-add">Hinzufügen</button>
<button v-if="hasTrainingToday" @click="loadParticipantsFromTraining" class="training-btn">
📅 Aus Trainingstag laden
</button>
</div>
</div>
</div>
</section>
<section v-if="isGroupTournament" class="group-controls">
@@ -246,6 +246,7 @@
<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>
@@ -262,6 +263,9 @@
<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 }]"
@@ -1044,63 +1048,23 @@ export default {
},
groupRankings() {
const byGroup = {};
// Die Teilnehmer kommen bereits sortiert vom Backend mit allen benötigten Statistiken
const rankings = {};
this.groups.forEach(g => {
byGroup[g.groupId] = g.participants.map(p => ({
rankings[g.groupId] = g.participants.map(p => ({
id: p.id,
name: p.name,
seeded: p.seeded || false,
points: 0,
setsWon: 0,
setsLost: 0,
setDiff: 0,
position: p.position || 0,
points: p.points || 0,
setsWon: p.setsWon || 0,
setsLost: p.setsLost || 0,
setDiff: p.setDiff || 0,
pointsWon: p.pointsWon || 0,
pointsLost: p.pointsLost || 0,
pointRatio: p.pointRatio || 0
}));
});
this.matches.forEach(m => {
if (!m.isFinished || m.round !== 'group') return;
const [s1, s2] = m.result.split(':').map(n => +n);
const arr = byGroup[m.groupId];
if (!arr) return;
const e1 = arr.find(x => x.id === m.player1.id);
const e2 = arr.find(x => x.id === m.player2.id);
if (!e1 || !e2) return;
if (s1 > s2) {
e1.points += 1; // Sieger bekommt +1
e2.points -= 1; // Verlierer bekommt -1
} else if (s2 > s1) {
e2.points += 1; // Sieger bekommt +1
e1.points -= 1; // Verlierer bekommt -1
}
e1.setsWon += s1; e1.setsLost += s2;
e2.setsWon += s2; e2.setsLost += s1;
});
const rankings = {};
Object.entries(byGroup).forEach(([gid, arr]) => {
arr.forEach(p => p.setDiff = p.setsWon - p.setsLost);
arr.sort((a, b) => {
if (b.points !== a.points) return b.points - a.points;
if (b.setDiff !== a.setDiff) return b.setDiff - a.setDiff;
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
return a.name.localeCompare(b.name);
});
// Weise Positionen zu, wobei Spieler mit identischen Werten den gleichen Platz bekommen
let currentPosition = 1;
rankings[gid] = arr.map((p, i) => {
// Wenn nicht der erste Spieler und die Werte identisch sind, verwende die gleiche Position
if (i > 0) {
const prev = arr[i - 1];
if (prev.points === p.points &&
prev.setDiff === p.setDiff &&
prev.setsWon === p.setsWon) {
// Gleicher Platz wie Vorgänger
return { ...p, position: currentPosition };
}
}
// Neuer Platz
currentPosition = i + 1;
return { ...p, position: currentPosition };
});
});
return rankings;
},
@@ -1335,7 +1299,14 @@ export default {
selectedDate: {
immediate: true,
handler: async function (val) {
if (val === 'new') return;
if (val === 'new') {
// Leere die Matches-Liste, wenn "neues Turnier" ausgewählt wird
this.matches = [];
this.groups = [];
this.showKnockout = false;
this.showParticipants = true;
return;
}
await this.loadTournamentData();
}
}
@@ -1507,10 +1478,28 @@ export default {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
// Lade Gruppen zuerst, damit wir groupNumber initialisieren können
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
// Setze Gruppen neu, um Vue-Reaktivität sicherzustellen
this.groups = [...gRes.data];
// Erstelle Mapping von groupId zu groupNumber
const groupIdToNumberMap = this.groups.reduce((m, g) => {
m[g.groupId] = g.groupNumber;
return m;
}, {});
// Stelle sicher, dass seeded-Feld vorhanden ist (für alte Einträge)
// Initialisiere auch groupNumber basierend auf groupId
this.participants = pRes.data.map(p => ({
...p,
seeded: p.seeded || false
seeded: p.seeded || false,
groupNumber: p.groupId ? (groupIdToNumberMap[p.groupId] || null) : null
}));
// Lade externe Teilnehmer (nur bei allowsExternal = true)
@@ -1523,7 +1512,8 @@ export default {
this.externalParticipants = extRes.data.map(p => ({
...p,
seeded: p.seeded || false,
isExternal: true
isExternal: true,
groupNumber: p.groupId ? (groupIdToNumberMap[p.groupId] || null) : null
}));
} catch (error) {
console.error('Fehler beim Laden der externen Teilnehmer:', error);
@@ -1532,13 +1522,6 @@ export default {
} else {
this.externalParticipants = [];
}
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
this.groups = gRes.data;
const mRes = await apiClient.get(
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
);
@@ -2288,46 +2271,42 @@ export default {
},
async updateParticipantGroup(participant, event) {
const groupNumber = parseInt(event.target.value);
const value = event.target.value;
const groupNumber = value === '' || value === 'null' ? null : parseInt(value);
console.log('[updateParticipantGroup] Updating participant:', participant.id, 'to groupNumber:', groupNumber, 'isExternal:', participant.isExternal);
// Aktualisiere lokal
participant.groupNumber = groupNumber;
// Bereite alle Teilnehmer-Zuordnungen vor (interne und externe, wenn allowsExternal = true)
const assignments = this.allowsExternal
? [
...this.participants.map(p => ({
participantId: p.id,
groupNumber: p.groupNumber || null,
isExternal: false
})),
...this.externalParticipants.map(p => ({
participantId: p.id,
groupNumber: p.groupNumber || null,
isExternal: true
}))
]
: this.participants.map(p => ({
participantId: p.id,
groupNumber: p.groupNumber || null
}));
// Sende an Backend
// Sende nur diesen einen Teilnehmer an Backend
try {
await apiClient.post('/tournament/groups/manual', {
const response = await apiClient.put('/tournament/participant/group', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
assignments: assignments,
numberOfGroups: this.numberOfGroups,
maxGroupSize: this.maxGroupSize
participantId: participant.id,
groupNumber: groupNumber,
isExternal: participant.isExternal || false
});
console.log('[updateParticipantGroup] Response:', response.data);
// Lade Daten neu, um die aktualisierten Gruppen zu erhalten
await this.loadTournamentData();
// Verwende die Response-Daten direkt, um die Gruppen zu aktualisieren
if (response.data && Array.isArray(response.data)) {
// Setze Gruppen neu, um Vue-Reaktivität sicherzustellen
this.groups = [...response.data];
console.log('[updateParticipantGroup] Updated groups:', this.groups);
} else {
// Fallback: Lade Daten neu
await this.loadTournamentData();
}
// Force Vue update, um sicherzustellen, dass die Gruppenübersicht aktualisiert wird
this.$forceUpdate();
} catch (error) {
console.error('Fehler beim Aktualisieren der Gruppe:', error);
// Bei Fehler: Lade Daten neu
// Bei Fehler: Lade Daten neu und setze groupNumber zurück
participant.groupNumber = participant.groupId ? this.groups.find(g => g.groupId === participant.groupId)?.groupNumber || null : null;
await this.loadTournamentData();
this.$forceUpdate();
}
},