feat(tournament): add participant gave-up functionality and UI updates

- Implemented setParticipantGaveUp and setExternalParticipantGaveUp methods in tournamentController to handle participant resignation.
- Updated ExternalTournamentParticipant and TournamentMember models to include a gaveUp field for tracking resignation status.
- Enhanced tournamentRoutes to include new endpoints for updating gave-up status.
- Modified TournamentGroupsTab and TournamentParticipantsTab components to display and manage gave-up status visually.
- Added localization strings for "gave up" and related hints in German.
- Updated TournamentResultsTab to reflect gave-up status in match results.
This commit is contained in:
Torsten Schulz (local)
2026-01-30 22:45:54 +01:00
parent 18a191f686
commit 7e1b09fa97
11 changed files with 344 additions and 41 deletions

View File

@@ -434,6 +434,20 @@ export const updateParticipantSeeded = async (req, res) => {
}
};
export const setParticipantGaveUp = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { gaveUp } = req.body;
try {
await tournamentService.setParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
} catch (err) {
console.error('[setParticipantGaveUp] Error:', err);
res.status(500).json({ error: err.message });
}
};
export const deleteMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId, set } = req.body;
@@ -555,6 +569,20 @@ export const updateExternalParticipantSeeded = async (req, res) => {
}
};
export const setExternalParticipantGaveUp = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { gaveUp } = req.body;
try {
await tournamentService.setExternalParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
} catch (error) {
console.error('[setExternalParticipantGaveUp] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Tournament Classes
export const getTournamentClasses = async (req, res) => {
const { authcode: token } = req.headers;

View File

@@ -0,0 +1,8 @@
-- Add gave_up (Aufgabe) to tournament participants
-- Wenn ein Spieler aufgibt: alle seine Spiele zählen für den Gegner (11:0), beide aufgegeben = 0:0, kein Sieger
ALTER TABLE `tournament_member`
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;
ALTER TABLE `external_tournament_participant`
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;

View File

@@ -88,6 +88,12 @@ const ExternalTournamentParticipant = sequelize.define('ExternalTournamentPartic
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
gaveUp: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'gave_up'
}
}, {
underscored: true,

View File

@@ -30,6 +30,12 @@ const TournamentMember = sequelize.define('TournamentMember', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
gaveUp: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'gave_up'
}
}, {
underscored: true,

View File

@@ -20,6 +20,7 @@ import {
resetMatches,
removeParticipant,
updateParticipantSeeded,
setParticipantGaveUp,
deleteMatchResult,
reopenMatch,
deleteKnockoutMatches,
@@ -28,6 +29,7 @@ import {
getExternalParticipants,
removeExternalParticipant,
updateExternalParticipantSeeded,
setExternalParticipantGaveUp,
getTournamentClasses,
addTournamentClass,
updateTournamentClass,
@@ -54,6 +56,7 @@ router.post('/participant', authenticate, addParticipant);
router.post('/participants', authenticate, getParticipants);
router.delete('/participant', authenticate, removeParticipant);
router.put('/participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateParticipantSeeded);
router.put('/participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setParticipantGaveUp);
router.post('/modus', authenticate, setModus);
router.post('/groups/reset', authenticate, resetGroups);
router.post('/matches/reset', authenticate, resetMatches);
@@ -82,6 +85,7 @@ router.post('/external-participant', authenticate, addExternalParticipant);
router.post('/external-participants', authenticate, getExternalParticipants);
router.delete('/external-participant', authenticate, removeExternalParticipant);
router.put('/external-participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateExternalParticipantSeeded);
router.put('/external-participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setExternalParticipantGaveUp);
// Tournament Classes
router.get('/classes/:clubId/:tournamentId', authenticate, getTournamentClasses);

View File

@@ -1582,6 +1582,7 @@ class TournamentService {
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const winningSets = tournament.winningSets || 3;
let groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{
@@ -1672,6 +1673,7 @@ class TournamentService {
? `${pairing.external2.firstName} ${pairing.external2.lastName}`
: 'Unbekannt';
const pairingGaveUp = !!(pairing.member1?.gaveUp || pairing.member2?.gaveUp || pairing.external1?.gaveUp || pairing.external2?.gaveUp);
stats[`pairing_${pairing.id}`] = {
id: `pairing_${pairing.id}`,
pairingId: pairing.id,
@@ -1679,6 +1681,7 @@ class TournamentService {
seeded: pairing.seeded || false,
isExternal: false,
isPairing: true,
gaveUp: pairingGaveUp,
player1Id: pairing.member1Id || pairing.external1Id,
player2Id: pairing.member2Id || pairing.external2Id,
points: 0,
@@ -1700,6 +1703,7 @@ class TournamentService {
name: `${tm.member.firstName} ${tm.member.lastName}`,
seeded: tm.seeded || false,
isExternal: false,
gaveUp: !!tm.gaveUp,
points: 0,
setsWon: 0,
setsLost: 0,
@@ -1710,7 +1714,7 @@ class TournamentService {
matchesLost: 0
};
}
// Externe Teilnehmer
for (const ext of g.externalGroupMembers || []) {
stats[ext.id] = {
@@ -1718,6 +1722,7 @@ class TournamentService {
name: `${ext.firstName} ${ext.lastName}`,
seeded: ext.seeded || false,
isExternal: true,
gaveUp: !!ext.gaveUp,
points: 0,
setsWon: 0,
setsLost: 0,
@@ -1744,6 +1749,46 @@ class TournamentService {
);
if (!pairing1Key || !pairing2Key) continue;
const p1GaveUp = stats[pairing1Key].gaveUp;
const p2GaveUp = stats[pairing2Key].gaveUp;
if (p1GaveUp || p2GaveUp) {
if (p1GaveUp && p2GaveUp) {
stats[pairing1Key].setsWon += 0;
stats[pairing1Key].setsLost += 0;
stats[pairing2Key].setsWon += 0;
stats[pairing2Key].setsLost += 0;
} else if (p1GaveUp && !p2GaveUp) {
stats[pairing2Key].points += 1;
stats[pairing2Key].matchesWon += 1;
stats[pairing1Key].points -= 1;
stats[pairing1Key].matchesLost += 1;
stats[pairing1Key].setsWon += 0;
stats[pairing1Key].setsLost += winningSets;
stats[pairing2Key].setsWon += winningSets;
stats[pairing2Key].setsLost += 0;
const pts = 11 * winningSets;
stats[pairing1Key].pointsWon += 0;
stats[pairing1Key].pointsLost += pts;
stats[pairing2Key].pointsWon += pts;
stats[pairing2Key].pointsLost += 0;
} else {
stats[pairing1Key].points += 1;
stats[pairing1Key].matchesWon += 1;
stats[pairing2Key].points -= 1;
stats[pairing2Key].matchesLost += 1;
stats[pairing1Key].setsWon += winningSets;
stats[pairing1Key].setsLost += 0;
stats[pairing2Key].setsWon += 0;
stats[pairing2Key].setsLost += winningSets;
const pts = 11 * winningSets;
stats[pairing1Key].pointsWon += pts;
stats[pairing1Key].pointsLost += 0;
stats[pairing2Key].pointsWon += 0;
stats[pairing2Key].pointsLost += pts;
}
continue;
}
// Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen
if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue;
const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10));
@@ -1781,6 +1826,46 @@ class TournamentService {
} else {
// Bei Einzel: Normale Logik
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const p1GaveUp = stats[m.player1Id].gaveUp;
const p2GaveUp = stats[m.player2Id].gaveUp;
if (p1GaveUp || p2GaveUp) {
if (p1GaveUp && p2GaveUp) {
stats[m.player1Id].setsWon += 0;
stats[m.player1Id].setsLost += 0;
stats[m.player2Id].setsWon += 0;
stats[m.player2Id].setsLost += 0;
} else if (p1GaveUp && !p2GaveUp) {
stats[m.player2Id].points += 1;
stats[m.player2Id].matchesWon += 1;
stats[m.player1Id].points -= 1;
stats[m.player1Id].matchesLost += 1;
stats[m.player1Id].setsWon += 0;
stats[m.player1Id].setsLost += winningSets;
stats[m.player2Id].setsWon += winningSets;
stats[m.player2Id].setsLost += 0;
const pts = 11 * winningSets;
stats[m.player1Id].pointsWon += 0;
stats[m.player1Id].pointsLost += pts;
stats[m.player2Id].pointsWon += pts;
stats[m.player2Id].pointsLost += 0;
} else {
stats[m.player1Id].points += 1;
stats[m.player1Id].matchesWon += 1;
stats[m.player2Id].points -= 1;
stats[m.player2Id].matchesLost += 1;
stats[m.player1Id].setsWon += winningSets;
stats[m.player1Id].setsLost += 0;
stats[m.player2Id].setsWon += 0;
stats[m.player2Id].setsLost += winningSets;
const pts = 11 * winningSets;
stats[m.player1Id].pointsWon += pts;
stats[m.player1Id].pointsLost += 0;
stats[m.player2Id].pointsWon += 0;
stats[m.player2Id].pointsLost += pts;
}
continue;
}
// Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen
if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue;
const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10));
@@ -2077,7 +2162,8 @@ class TournamentService {
lastName: ep.lastName,
club: ep.club,
gender: ep.gender,
birthDate: ep.birthDate
birthDate: ep.birthDate,
gaveUp: !!ep.gaveUp
};
} else {
plain.player1 = null;
@@ -2095,18 +2181,49 @@ class TournamentService {
lastName: ep.lastName,
club: ep.club,
gender: ep.gender,
birthDate: ep.birthDate
birthDate: ep.birthDate,
gaveUp: !!ep.gaveUp
};
} else {
plain.player2 = null;
}
// Virtuelle Ergebnisse bei Aufgabe: ein Spieler aufgegeben → Gegner 11:0 je Satz; beide aufgegeben → 0:0, kein Sieger
const winningSets = t.winningSets || 3;
const p1GaveUp = plain.player1?.gaveUp;
const p2GaveUp = plain.player2?.gaveUp;
if (p1GaveUp || p2GaveUp) {
plain.gaveUpMatch = true;
if (p1GaveUp && p2GaveUp) {
plain.result = '0:0';
plain.isFinished = true;
plain.tournamentResults = [{ set: 1, pointsPlayer1: 0, pointsPlayer2: 0 }];
} else if (p1GaveUp && !p2GaveUp) {
plain.result = `0:${winningSets}`;
plain.isFinished = true;
plain.tournamentResults = Array.from({ length: winningSets }, (_, i) => ({ set: i + 1, pointsPlayer1: 0, pointsPlayer2: 11 }));
} else {
plain.result = `${winningSets}:0`;
plain.isFinished = true;
plain.tournamentResults = Array.from({ length: winningSets }, (_, i) => ({ set: i + 1, pointsPlayer1: 11, pointsPlayer2: 0 }));
}
}
return plain;
});
return finalMatches;
}
async _matchHasGaveUpPlayer(tournamentId, player1Id, player2Id) {
const ids = [player1Id, player2Id].filter(Boolean);
if (ids.length === 0) return false;
const members = await TournamentMember.findAll({ where: { tournamentId, id: { [Op.in]: ids } } });
const externals = await ExternalTournamentParticipant.findAll({ where: { tournamentId, id: { [Op.in]: ids } } });
if (members.some(m => m.gaveUp) || externals.some(e => e.gaveUp)) return true;
return false;
}
// 12. Satz-Ergebnis hinzufügen/überschreiben
async addMatchResult(userToken, clubId, tournamentId, matchId, set, result) {
await checkAccess(userToken, clubId);
@@ -2119,6 +2236,9 @@ class TournamentService {
// Lade Match, um isFinished zu prüfen
const match = await TournamentMatch.findByPk(matchId);
if (!match || match.tournamentId != tournamentId) throw new Error('Match nicht gefunden');
if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) {
throw new Error('Ergebnisse von Spielen mit aufgegebenen Spielern können nicht bearbeitet werden.');
}
const existing = await TournamentResult.findOne({ where: { matchId, set } });
if (existing) {
@@ -2163,6 +2283,9 @@ class TournamentService {
include: [{ model: TournamentResult, as: "tournamentResults" }]
});
if (!match) throw new Error("Match nicht gefunden");
if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) {
throw new Error('Spiele mit aufgegebenen Spielern können nicht abgeschlossen werden.');
}
let win = 0, lose = 0;
for (const r of match.tournamentResults) {
@@ -2334,9 +2457,10 @@ class TournamentService {
]
});
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId, round: "group", isFinished: true },
where: { tournamentId, round: "group" },
include: [{ model: TournamentResult, as: "tournamentResults" }]
});
const winningSets = tournament.winningSets || 3;
// Lade alle Klassen, um zu prüfen, ob es sich um Doppel-Klassen handelt
const tournamentClasses = await TournamentClass.findAll({ where: { tournamentId } });
@@ -2352,44 +2476,57 @@ class TournamentService {
const stats = {};
// Interne Teilnehmer
for (const tm of g.tournamentGroupMembers || []) {
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false, matchesWon: 0, matchesLost: 0 };
stats[tm.id] = { member: tm, gaveUp: !!tm.gaveUp, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false, matchesWon: 0, matchesLost: 0 };
}
// Externe Teilnehmer
for (const ext of g.externalGroupMembers || []) {
stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true, matchesWon: 0, matchesLost: 0 };
stats[ext.id] = { member: ext, gaveUp: !!ext.gaveUp, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true, matchesWon: 0, matchesLost: 0 };
}
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
if (p1 > p2) {
stats[m.player1Id].points += 1; // Sieger bekommt +1
stats[m.player1Id].matchesWon += 1;
stats[m.player2Id].points -= 1; // Verlierer bekommt -1
stats[m.player2Id].matchesLost += 1;
const p1GaveUp = stats[m.player1Id].gaveUp;
const p2GaveUp = stats[m.player2Id].gaveUp;
let p1, p2, p1Points = 0, p2Points = 0;
if (p1GaveUp || p2GaveUp) {
if (p1GaveUp && p2GaveUp) {
p1 = 0; p2 = 0;
} else if (p1GaveUp && !p2GaveUp) {
p1 = 0; p2 = winningSets;
p2Points = 11 * winningSets;
} else {
p1 = winningSets; p2 = 0;
p1Points = 11 * winningSets;
}
} else if (m.isFinished && m.result && typeof m.result === 'string' && m.result.includes(':')) {
[p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
if (m.tournamentResults && m.tournamentResults.length > 0) {
for (const r of m.tournamentResults) {
p1Points += Math.abs(r.pointsPlayer1 || 0);
p2Points += Math.abs(r.pointsPlayer2 || 0);
}
}
} else {
stats[m.player2Id].points += 1; // Sieger bekommt +1
continue;
}
if (p1 > p2) {
stats[m.player1Id].points += 1;
stats[m.player1Id].matchesWon += 1;
stats[m.player2Id].points -= 1;
stats[m.player2Id].matchesLost += 1;
} else if (p2 > p1) {
stats[m.player2Id].points += 1;
stats[m.player2Id].matchesWon += 1;
stats[m.player1Id].points -= 1; // Verlierer bekommt -1
stats[m.player1Id].points -= 1;
stats[m.player1Id].matchesLost += 1;
}
stats[m.player1Id].setsWon += p1;
stats[m.player1Id].setsLost += p2;
stats[m.player2Id].setsWon += p2;
stats[m.player2Id].setsLost += p1;
// Berechne gespielte Punkte aus tournamentResults
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
// Verwende absolute Werte, falls negative Werte gespeichert wurden
p1Points += Math.abs(r.pointsPlayer1 || 0);
p2Points += Math.abs(r.pointsPlayer2 || 0);
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
stats[m.player2Id].pointsWon += p2Points;
stats[m.player2Id].pointsLost += p1Points;
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
stats[m.player2Id].pointsWon += p2Points;
stats[m.player2Id].pointsLost += p1Points;
}
// Berechne Punktverhältnis und absolute Differenz für jeden Spieler
@@ -2413,17 +2550,30 @@ class TournamentService {
// 5. Bei Spielpunktgleichheit: Wer mehr Spielpunkte erzielt hat
if (b.pointsWon !== a.pointsWon) return b.pointsWon - a.pointsWon;
// 6. Direkter Vergleich (Sieger weiter oben)
const directMatch = groupMatches.find(m =>
const directMatch = groupMatches.find(m =>
m.groupId === g.id &&
((m.player1Id === a.member.id && m.player2Id === b.member.id) ||
(m.player1Id === b.member.id && m.player2Id === a.member.id))
);
if (directMatch) {
const [s1, s2] = directMatch.result.split(":").map(n => parseInt(n, 10));
const aWon = (directMatch.player1Id === a.member.id && s1 > s2) ||
const p1Gu = stats[directMatch.player1Id]?.gaveUp;
const p2Gu = stats[directMatch.player2Id]?.gaveUp;
let s1, s2;
if (p1Gu && p2Gu) {
s1 = 0; s2 = 0;
} else if (p1Gu && !p2Gu) {
s1 = 0; s2 = winningSets;
} else if (!p1Gu && p2Gu) {
s1 = winningSets; s2 = 0;
} else if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) {
[s1, s2] = directMatch.result.split(":").map(n => parseInt(n, 10));
} else {
return a.member.id - b.member.id;
}
const aWon = (directMatch.player1Id === a.member.id && s1 > s2) ||
(directMatch.player2Id === a.member.id && s2 > s1);
if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben
return 1; // b hat gewonnen -> b kommt weiter oben
if (aWon) return -1;
return 1;
}
// Fallback: Nach ID
return a.member.id - b.member.id;
@@ -2942,13 +3092,35 @@ class TournamentService {
await participant.save();
}
async setParticipantGaveUp(userToken, clubId, tournamentId, participantId, gaveUp) {
await checkAccess(userToken, clubId);
const participant = await TournamentMember.findOne({
where: { id: participantId, tournamentId }
});
if (!participant) throw new Error('Teilnehmer nicht gefunden');
participant.gaveUp = !!gaveUp;
await participant.save();
}
async setExternalParticipantGaveUp(userToken, clubId, tournamentId, participantId, gaveUp) {
await checkAccess(userToken, clubId);
const participant = await ExternalTournamentParticipant.findOne({
where: { id: participantId, tournamentId }
});
if (!participant) throw new Error('Externer Teilnehmer nicht gefunden');
participant.gaveUp = !!gaveUp;
await participant.save();
}
// services/tournamentService.js
async deleteMatchResult(userToken, clubId, tournamentId, matchId, setToDelete) {
await checkAccess(userToken, clubId);
// Match existiert?
const match = await TournamentMatch.findOne({ where: { id: matchId, tournamentId } });
if (!match) throw new Error('Match nicht gefunden');
if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) {
throw new Error('Ergebnisse von Spielen mit aufgegebenen Spielern können nicht bearbeitet werden.');
}
// Satz löschen
await TournamentResult.destroy({ where: { matchId, set: setToDelete } });
@@ -2977,6 +3149,9 @@ class TournamentService {
if (!match) {
throw new Error("Match nicht gefunden");
}
if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) {
throw new Error('Spiele mit aufgegebenen Spielern können nicht wieder geöffnet werden.');
}
// Nur den AbschlussStatus zurücksetzen, nicht die Einzelsätze
match.isFinished = false;