feat(tournament): add group match creation and enhance match handling

- Implemented createGroupMatches function to generate matches for existing groups without altering group assignments.
- Updated resetMatches function to support optional class filtering when resetting group matches.
- Enhanced frontend components to filter and display group matches based on selected class, improving user experience.
- Adjusted tournament results display to reflect accurate match statistics, including wins and losses.
This commit is contained in:
Torsten Schulz (local)
2025-12-17 13:38:40 +01:00
parent 4b4c48a50f
commit dc084806ab
7 changed files with 676 additions and 186 deletions

View File

@@ -90,13 +90,16 @@
<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.matchesWon || 0) * 2 }}:{{ (pl.matchesLost || 0) * 2 }}</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) }})
{{ Math.abs(pl.pointsWon || 0) }}:{{ Math.abs(pl.pointsLost || 0) }}
<span v-if="(Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) !== 0" class="points-diff">
({{ (Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) >= 0 ? '+' : '' }}{{ Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0) }})
</span>
</td>
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
:key="`match-${pl.id}-${opponent.id}`"
@@ -116,12 +119,12 @@
</div>
</template>
</template>
<div v-if="!matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<div v-if="filteredGroupMatches.length === 0" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('create-matches')" class="btn-primary">
Gruppenspiele berechnen
</button>
</div>
<div v-if="matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<div v-if="filteredGroupMatches.length > 0" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('reset-matches')" class="trash-btn">
🗑 {{ $t('tournaments.resetGroupMatches') }}
</button>
@@ -207,7 +210,34 @@ export default {
'create-matches',
'highlight-match'
],
computed: {
filteredGroupMatches() {
return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group'));
}
},
methods: {
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return matches;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return matches.filter(m => m.classId === null || m.classId === undefined);
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return matches;
}
return matches.filter(m => {
const matchClassId = m.classId;
if (matchClassId === null || matchClassId === undefined) {
return false;
}
return Number(matchClassId) === selectedId;
});
},
shouldShowClass(classId) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {

View File

@@ -7,7 +7,7 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section v-if="groupMatches.length" class="group-matches">
<section v-if="filteredGroupMatches.length" class="group-matches">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<table>
<thead>
@@ -21,7 +21,7 @@
</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)">
<tr v-for="m in filteredGroupMatches" :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)">
@@ -95,22 +95,22 @@
</tbody>
</table>
</section>
<div v-if="participants.length > 1 && !groupMatches.length && !knockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
<div v-if="participants.length > 1 && !filteredGroupMatches.length && !filteredKnockoutMatches.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">
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 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">
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 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">
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
<h4>{{ $t('tournaments.koRound') }}</h4>
<table>
<thead>
@@ -124,7 +124,7 @@
</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)">
<tr v-for="m in filteredKnockoutMatches" :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>
@@ -188,9 +188,9 @@
</tbody>
</table>
</section>
<section v-if="Object.keys(groupedRankingList).length > 0" class="ranking">
<section v-if="Object.keys(filteredGroupedRankingList).length > 0" class="ranking">
<h4>Rangliste</h4>
<template v-for="(classKey, idx) in Object.keys(groupedRankingList).sort((a, b) => {
<template v-for="(classKey, idx) in Object.keys(filteredGroupedRankingList).sort((a, b) => {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
@@ -205,7 +205,7 @@
</tr>
</thead>
<tbody>
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey].filter(e => Number(e.position) <= 3)" :key="`${entry.member.id}-${entryIdx}`">
<tr v-for="(entry, entryIdx) in filteredGroupedRankingList[classKey].filter(e => Number(e.position) <= 3)" :key="`${entry.member.id}-${entryIdx}`">
<td>{{ entry.position }}.</td>
<td>
{{ entry.member.firstName }}
@@ -289,6 +289,71 @@ export default {
required: true
}
},
computed: {
filteredGroupMatches() {
return this.filterMatchesByClass(this.groupMatches);
},
filteredKnockoutMatches() {
return this.filterMatchesByClass(this.knockoutMatches);
},
filteredGroupedRankingList() {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return this.groupedRankingList;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
const result = {};
if (this.groupedRankingList['null']) {
result['null'] = this.groupedRankingList['null'];
}
return result;
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return this.groupedRankingList;
}
const result = {};
const classKey = String(selectedId);
if (this.groupedRankingList[classKey]) {
result[classKey] = this.groupedRankingList[classKey];
}
return result;
},
numberOfGroupsForSelectedClass() {
// Zähle direkt die Gruppen für die ausgewählte Klasse
// Nur Stage 1 Gruppen (stageId null/undefined) zählen
// Und nur Gruppen mit mindestens einem Teilnehmer
let groupsToCount = this.groups.filter(g =>
(!g.stageId || g.stageId === null || g.stageId === undefined) &&
g.participants && Array.isArray(g.participants) && g.participants.length > 0
);
// Wenn keine Klasse ausgewählt ist, zähle alle Stage 1 Gruppen
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return groupsToCount.length;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return groupsToCount.filter(g => g.classId === null || g.classId === undefined).length;
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return groupsToCount.length;
}
return groupsToCount.filter(g => {
if (g.classId === null || g.classId === undefined) {
return false;
}
return Number(g.classId) === selectedId;
}).length;
}
},
emits: [
'update:selectedViewClass',
'update:activeMatchId',
@@ -305,6 +370,28 @@ export default {
'reset-knockout'
],
methods: {
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return matches;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return matches.filter(m => m.classId === null || m.classId === undefined);
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return matches;
}
return matches.filter(m => {
const matchClassId = m.classId;
if (matchClassId === null || matchClassId === undefined) {
return false;
}
return Number(matchClassId) === selectedId;
});
},
getGroupClassName(groupId) {
if (!groupId) return '';
const group = this.groups.find(g => g.groupId === groupId);

View File

@@ -175,7 +175,7 @@
@randomize-groups="randomizeGroups()"
@reset-groups="resetGroups()"
@reset-matches="resetMatches()"
@create-matches="startMatches()"
@create-matches="createMatches()"
@highlight-match="highlightMatch"
/>
@@ -487,9 +487,11 @@ export default {
setsWon: p.setsWon || 0,
setsLost: p.setsLost || 0,
setDiff: p.setDiff || 0,
pointsWon: p.pointsWon || 0,
pointsLost: p.pointsLost || 0,
pointRatio: p.pointRatio || 0
pointsWon: Math.abs(p.pointsWon || 0),
pointsLost: Math.abs(p.pointsLost || 0),
pointRatio: p.pointRatio || 0,
matchesWon: p.matchesWon || 0,
matchesLost: p.matchesLost || 0
}));
});
return rankings;
@@ -1314,6 +1316,41 @@ export default {
this.showKnockout = this.matches.some(m => m.round !== 'group');
},
async loadMatches() {
// Lade nur die Matches, ohne die Teilnehmer-Daten zu überschreiben
const mRes = await apiClient.get(
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
);
const grpMap = this.groups.reduce((m, g) => {
m[g.groupId] = g.groupNumber;
return m;
}, {});
this.matches = mRes.data.map(m => {
// 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,
groupNumber: groupNumber,
groupRound: groupRound,
resultInput: '',
isActive: m.isActive || false
};
});
// Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden
this.showParticipants = this.matches.length === 0;
this.showKnockout = this.matches.some(m => m.round !== 'group');
},
async handleTournamentChanged(data) {
if (!data || !data.tournamentId) {
@@ -1855,6 +1892,25 @@ export default {
await this.loadTournamentData();
},
async createMatches() {
if (!this.isGroupTournament) {
return;
}
if (!this.groups.length) {
await this.createGroups();
}
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
? this.selectedViewClass
: null;
await apiClient.post('/tournament/matches/create', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
classId: classId
});
await this.loadTournamentData();
},
async onModusChange() {
const type = this.isGroupTournament ? 'groups' : 'knockout';
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
@@ -1877,9 +1933,14 @@ export default {
},
async resetMatches() {
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
? this.selectedViewClass
: null;
await apiClient.post('/tournament/matches/reset', {
clubId: this.currentClub,
tournamentId: this.selectedDate
tournamentId: this.selectedDate,
classId: classId
});
await this.loadTournamentData();
},
@@ -2201,8 +2262,17 @@ export default {
console.log('[updateParticipantGroup] Updating participant:', participant.id, 'to groupNumber:', groupNumber, 'isExternal:', participant.isExternal);
// Aktualisiere lokal
participant.groupNumber = groupNumber;
// Speichere die alte groupNumber für den Fall eines Fehlers
const oldGroupNumber = participant.groupNumber;
// Aktualisiere lokal sofort für responsive UI (mit Vue-Reaktivität)
if (this.$set) {
// Vue 2
this.$set(participant, 'groupNumber', groupNumber);
} else {
// Vue 3
participant.groupNumber = groupNumber;
}
// Sende nur diesen einen Teilnehmer an Backend
try {
@@ -2221,17 +2291,86 @@ export default {
this.groups = [...response.data];
console.log('[updateParticipantGroup] Updated groups:', this.groups);
} else {
// Fallback: Lade Daten neu
await this.loadTournamentData();
// Fallback: Lade Gruppen neu
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const groupsData = Array.isArray(gRes.data)
? gRes.data
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
this.groups = [...groupsData];
}
// Force Vue update, um sicherzustellen, dass die Gruppenübersicht aktualisiert wird
this.$forceUpdate();
// Aktualisiere auch die groupId des Teilnehmers basierend auf der neuen groupNumber
if (groupNumber !== null) {
const newGroup = this.groups.find(g => g.groupNumber === groupNumber);
if (newGroup) {
if (this.$set) {
this.$set(participant, 'groupId', newGroup.groupId);
} else {
participant.groupId = newGroup.groupId;
}
} else {
// Gruppe nicht gefunden, setze groupId auf null
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
} else {
// groupNumber ist null, also auch groupId auf null
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
// Lade Matches neu, da sich die Gruppenzuordnung geändert hat
await this.loadMatches();
} catch (error) {
console.error('Fehler beim Aktualisieren der Gruppe:', error);
// 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();
// Bei Fehler: Setze groupNumber zurück auf den alten Wert
if (this.$set) {
this.$set(participant, 'groupNumber', oldGroupNumber);
} else {
participant.groupNumber = oldGroupNumber;
}
// Lade Gruppen neu, um sicherzustellen, dass groupId korrekt ist
try {
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const groupsData = Array.isArray(gRes.data)
? gRes.data
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
this.groups = [...groupsData];
// Setze groupId basierend auf dem alten groupNumber
if (oldGroupNumber !== null) {
const oldGroup = this.groups.find(g => g.groupNumber === oldGroupNumber);
const oldGroupId = oldGroup ? oldGroup.groupId : null;
if (this.$set) {
this.$set(participant, 'groupId', oldGroupId);
} else {
participant.groupId = oldGroupId;
}
} else {
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
} catch (loadError) {
console.error('Fehler beim Neuladen der Gruppen:', loadError);
}
}
},