diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index 669e764..46b1b2a 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -4,6 +4,42 @@ import { emitTournamentChanged } from '../services/socketService.js'; import TournamentClass from '../models/TournamentClass.js'; import HttpError from '../exceptions/HttpError.js'; +// Pools (zusammengelegte Gruppenphasen) +export const mergeClassesIntoPool = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, sourceClassId, targetClassId, strategy, outOfCompetitionForSource } = req.body; + try { + await tournamentService.mergeClassesIntoPool( + token, + clubId, + tournamentId, + sourceClassId, + targetClassId, + strategy, // 'singleGroup' | 'distribute' + !!outOfCompetitionForSource + ); + // Broadcast + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ success: true }); + } catch (error) { + console.error('[mergeClassesIntoPool] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +export const resetPool = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, poolId } = req.body; + try { + await tournamentService.resetPool(token, clubId, tournamentId, poolId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ success: true }); + } catch (error) { + console.error('[resetPool] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + // 1. Alle Turniere eines Vereins export const getTournaments = async (req, res) => { const { authcode: token } = req.headers; diff --git a/backend/migrations/20260107_add_pool_and_out_of_competition.sql b/backend/migrations/20260107_add_pool_and_out_of_competition.sql new file mode 100644 index 0000000..aca68ba --- /dev/null +++ b/backend/migrations/20260107_add_pool_and_out_of_competition.sql @@ -0,0 +1,11 @@ +-- Add pool_id to tournament_group for pooled group phases +ALTER TABLE `tournament_group` + ADD COLUMN `pool_id` INT NULL AFTER `class_id`; + +-- Add out_of_competition flags +ALTER TABLE `tournament_member` + ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`; + +ALTER TABLE `external_tournament_participant` + ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`; + diff --git a/backend/models/ExternalTournamentParticipant.js b/backend/models/ExternalTournamentParticipant.js index 49dbeb7..fb7592a 100644 --- a/backend/models/ExternalTournamentParticipant.js +++ b/backend/models/ExternalTournamentParticipant.js @@ -83,6 +83,11 @@ const ExternalTournamentParticipant = sequelize.define('ExternalTournamentPartic classId: { type: DataTypes.INTEGER, allowNull: true + }, + outOfCompetition: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false } }, { underscored: true, diff --git a/backend/models/TournamentGroup.js b/backend/models/TournamentGroup.js index 2c47f29..77bf254 100644 --- a/backend/models/TournamentGroup.js +++ b/backend/models/TournamentGroup.js @@ -20,6 +20,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', { type: DataTypes.INTEGER, allowNull: true }, + poolId: { + type: DataTypes.INTEGER, + allowNull: true + }, }, { underscored: true, tableName: 'tournament_group', diff --git a/backend/models/TournamentMember.js b/backend/models/TournamentMember.js index a8a2c5a..a979445 100644 --- a/backend/models/TournamentMember.js +++ b/backend/models/TournamentMember.js @@ -25,6 +25,11 @@ const TournamentMember = sequelize.define('TournamentMember', { classId: { type: DataTypes.INTEGER, allowNull: true + }, + outOfCompetition: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false } }, { underscored: true, diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index f1a409a..2543476 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -40,6 +40,7 @@ import { updatePairing, deletePairing, } from '../controllers/tournamentController.js'; +import { mergeClassesIntoPool, resetPool } from '../controllers/tournamentController.js'; import { getStages, upsertStages, @@ -60,6 +61,9 @@ router.put('/groups', authenticate, createGroups); router.post('/groups/create', authenticate, createGroupsPerClass); router.post('/groups', authenticate, fillGroups); router.post('/matches/create', authenticate, createGroupMatches); +// Pools +router.post('/pools/merge', authenticate, mergeClassesIntoPool); +router.post('/pools/reset', authenticate, resetPool); router.get('/groups', authenticate, getGroups); router.post('/match/result', authenticate, addMatchResult); router.delete('/match/result', authenticate, deleteMatchResult); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 45810f8..62c33cd 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -96,6 +96,117 @@ function nextPowerOfTwo(n) { const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { + /** + * Klassen in einem gemeinsamen Pool zusammenführen. + * strategy: 'singleGroup' (alle A-Spieler in eine neue Gruppe der Zielklasse) | + * 'distribute' (A-Spieler gleichmäßig auf Ziel-Gruppen verteilen) + */ + async mergeClassesIntoPool(userToken, clubId, tournamentId, sourceClassId, targetClassId, strategy = 'distribute', outOfCompetitionForSource = false) { + await checkAccess(userToken, clubId); + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) { + throw new Error('Turnier nicht gefunden'); + } + if (!sourceClassId || !targetClassId) { + throw new Error('sourceClassId und targetClassId sind erforderlich'); + } + if (String(sourceClassId) === String(targetClassId)) { + throw new Error('Quelle und Zielklasse dürfen nicht identisch sein'); + } + + // Erzeuge eine neue poolId (einfacher Ansatz) + const maxExisting = await TournamentGroup.max('poolId', { where: { tournamentId } }); + const poolId = Number.isFinite(Number(maxExisting)) ? Number(maxExisting) + 1 : 1; + + // Lade Ziel-Gruppen + const targetGroups = await TournamentGroup.findAll({ + where: { tournamentId, classId: targetClassId }, + order: [['id', 'ASC']] + }); + if (!targetGroups || targetGroups.length === 0) { + throw new Error('Zielklasse hat keine Gruppen. Bitte zuerst Gruppen anlegen.'); + } + + // Markiere Zielgruppen mit poolId + for (const g of targetGroups) { + g.poolId = poolId; + await g.save(); + } + + // Lade Teilnehmer der Quellklasse (intern + extern) + const sourceInternals = await TournamentMember.findAll({ where: { tournamentId, classId: sourceClassId } }); + const sourceExternals = await ExternalTournamentParticipant.findAll({ where: { tournamentId, classId: sourceClassId } }); + + // Optional: „außer Konkurrenz“ für Source markieren + if (outOfCompetitionForSource) { + await Promise.all([ + ...sourceInternals.map(m => { m.outOfCompetition = true; return m.save(); }), + ...sourceExternals.map(e => { e.outOfCompetition = true; return e.save(); }) + ]); + } + + if (strategy === 'singleGroup') { + // Lege eine zusätzliche Gruppe in der Zielklasse an, nur für Source-Spieler + // Bestimme groupNumber: max + 1 + const maxGroupNumber = await TournamentGroup.max('id', { where: { tournamentId, classId: targetClassId } }); + const newGroup = await TournamentGroup.create({ + stageId: null, + tournamentId, + classId: targetClassId, + poolId + }); + + // Weisen wir allen Source-Spielern diese neue Gruppe zu + for (const m of sourceInternals) { + m.groupId = newGroup.id; + m.classId = targetClassId; // bleiben sie in Quelle? Für Pool reicht Zielgruppen-Zuordnung; Klasse lassen wir unverändert + await m.save(); + } + for (const e of sourceExternals) { + e.groupId = newGroup.id; + e.classId = targetClassId; + await e.save(); + } + } else { + // distribute: Gleichmäßig über bestehende Zielgruppen verteilen + const cyclic = [...targetGroups]; + let idx = 0; + const assignNext = () => { + const g = cyclic[idx % cyclic.length]; + idx++; + return g; + }; + for (const m of sourceInternals) { + const g = assignNext(); + m.groupId = g.id; + // Klasse beim Teilnehmer unverändert lassen; Gruppenspiele laufen trotzdem im Pool + await m.save(); + } + for (const e of sourceExternals) { + const g = assignNext(); + e.groupId = g.id; + await e.save(); + } + } + + // Optional: vorhandene Gruppenspiele neu erstellen, damit die Verteilung greift + await this.createGroupMatches(userToken, clubId, tournamentId, null); + return { poolId }; + } + + /** + * Pool zurücksetzen: Entfernt poolId von Gruppen des Pools. + * (Vereinfachter Reset – Teilnehmer bleiben in Gruppen; Matches kann der Nutzer löschen.) + */ + async resetPool(userToken, clubId, tournamentId, poolId) { + await checkAccess(userToken, clubId); + if (!poolId) throw new Error('poolId ist erforderlich'); + const groups = await TournamentGroup.findAll({ where: { tournamentId, poolId } }); + for (const g of groups) { + g.poolId = null; + await g.save(); + } + } // -------- Multi-Stage (Runden) V1 -------- async getTournamentStages(userToken, clubId, tournamentId) { await checkAccess(userToken, clubId); diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 8f71735..d31809a 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -57,6 +57,45 @@ + + +
+

{{ $t('tournaments.mergeClasses') || 'Klassen zusammenlegen (gemeinsame Gruppenphase)' }}

+
+ + + +
+
+ +
+

{{ $t('tournaments.groupsOverview') }}

@@ -209,11 +248,40 @@ export default { 'reset-matches', 'create-matches', 'highlight-match', - 'go-to-match' + 'go-to-match', + 'merge-pools' ], + data() { + return { + // Merge-UI (Pools) + mergeSourceClassId: null, + mergeTargetClassId: null, + mergeStrategy: 'distribute', // 'singleGroup' | 'distribute' + mergeSourceAsAK: false, + }; + }, computed: { filteredGroupMatches() { return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group')); + }, + mergePoolsReady() { + return !!( + this.mergeSourceClassId && + this.mergeTargetClassId && + String(this.mergeSourceClassId) !== String(this.mergeTargetClassId) + ); + }, + mergeSourceClassName() { + const id = this.mergeSourceClassId; + if (!id) return ''; + const c = (this.tournamentClasses || []).find(x => String(x.id) === String(id)); + return c ? c.name : ''; + }, + mergeOutOfCompetitionLabel() { + const base = this.$t && this.$t('tournaments.outOfCompetition'); + // Wenn Übersetzung vorhanden ist, ersetzen wir nur das "A" nicht zuverlässig -> lieber dynamisch bauen + const src = this.mergeSourceClassName || 'Quelle'; + return `Spieler aus ${src} außer Konkurrenz`; } }, methods: { @@ -399,8 +467,45 @@ export default { if (match) { this.$emit('go-to-match', match.id); } + }, + requestMergePools() { + if (!this.mergeSourceClassId || !this.mergeTargetClassId) return; + if (String(this.mergeSourceClassId) === String(this.mergeTargetClassId)) return; + this.$emit('merge-pools', { + sourceClassId: Number(this.mergeSourceClassId), + targetClassId: Number(this.mergeTargetClassId), + strategy: this.mergeStrategy, + outOfCompetitionForSource: !!this.mergeSourceAsAK, + }); } } }; + + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9a5cc57..9ce919b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -646,6 +646,7 @@ "encounter": "Begegnung", "result": "Ergebnis", "sets": "Sätze", + "setDiff": "Satzdifferenz", "createMatches": "Spiele erstellen", "startKORound": "K.o.-Runde starten", "deleteKORound": "K.o.-Runde", @@ -658,7 +659,19 @@ "errorCreatingTournament": "Fehler beim Erstellen des Turniers.", "pleaseSelectParticipant": "Bitte wählen Sie einen Teilnehmer aus!", "errorCreatingGroups": "Fehler beim Erstellen der Gruppen.", - "errorResettingKORound": "Fehler beim Zurücksetzen der K.o.-Runde." + "errorResettingKORound": "Fehler beim Zurücksetzen der K.o.-Runde.", + "mergeClasses": "Klassen zusammenlegen (gemeinsame Gruppenphase)", + "sourceClass": "Quelle", + "targetClass": "Ziel", + "strategy": "Strategie", + "mergeSingleGroup": "Alle Spieler aus A in eine Gruppe (bei B)", + "mergeDistribute": "Spieler aus A auf alle Gruppen (von B) verteilen", + "outOfCompetition": "Spieler aus A außer Konkurrenz", + "errorMergingClasses": "Fehler beim Zusammenlegen der Klassen.", + "apply": "Übernehmen" + }, + "tournament": { + "apply": "Übernehmen" }, "clubSettings": { "title": "Vereins-Einstellungen", diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index 17703a4..c5adbc3 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -178,6 +178,7 @@ @create-matches="createMatches()" @highlight-match="highlightMatch" @go-to-match="goToMatch" + @merge-pools="mergePools" /> @@ -2252,6 +2253,24 @@ export default { }); }, + async mergePools({ sourceClassId, targetClassId, strategy, outOfCompetitionForSource }) { + try { + await apiClient.post('/tournament/pools/merge', { + clubId: this.currentClub, + tournamentId: this.selectedDate, + sourceClassId, + targetClassId, + strategy, + outOfCompetitionForSource: !!outOfCompetitionForSource, + }); + await this.loadTournamentData(); + } catch (error) { + console.error('[mergePools] Error:', error); + const msg = (this.$t && this.$t('tournaments.errorMergingClasses')) || 'Fehler beim Zusammenlegen der Klassen.'; + await this.showInfo((this.$t && this.$t('messages.error')) || 'Fehler', msg, '', 'error'); + } + }, + async updateParticipantSeeded(participant, event) { const seeded = event.target.checked;