feat(tournament): implement class merging and pool management features
- Added mergeClassesIntoPool and resetPool functions in tournamentService to handle merging classes into a common pool and resetting pool assignments. - Introduced new API routes for merging and resetting pools in tournamentRoutes. - Enhanced TournamentGroupsTab component with UI for merging classes, including selection for source and target classes, strategy options, and out-of-competition settings. - Updated localization files to include new strings related to class merging functionality. - Modified TournamentTab to handle merge pool events and manage API interactions for merging classes.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,45 @@
|
||||
<button @click="$emit('create-groups')">{{ $t('tournaments.createGroups') }}</button>
|
||||
<button @click="$emit('randomize-groups')">{{ $t('tournaments.randomizeGroups') }}</button>
|
||||
<button @click="$emit('reset-groups')">{{ $t('tournaments.resetGroups') }}</button>
|
||||
|
||||
<!-- Klassen zusammenlegen (Pools) -->
|
||||
<div class="merge-pools-box">
|
||||
<h4>{{ $t('tournaments.mergeClasses') || 'Klassen zusammenlegen (gemeinsame Gruppenphase)' }}</h4>
|
||||
<div class="merge-pools-row">
|
||||
<label>
|
||||
{{ $t('tournaments.sourceClass') || 'Quelle' }}:
|
||||
<select v-model="mergeSourceClassId">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in tournamentClasses" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('tournaments.targetClass') || 'Ziel' }}:
|
||||
<select v-model="mergeTargetClassId">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in tournamentClasses" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="mergePoolsReady">
|
||||
<label>
|
||||
{{ $t('tournaments.strategy') || 'Strategie' }}:
|
||||
<select v-model="mergeStrategy">
|
||||
<option value="singleGroup">{{ $t('tournaments.mergeSingleGroup') || 'Alle Spieler aus A in eine Gruppe (bei B)' }}</option>
|
||||
<option value="distribute">{{ $t('tournaments.mergeDistribute') || 'Spieler aus A auf alle Gruppen (von B) verteilen' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="mergeSourceAsAK" />
|
||||
{{ mergeOutOfCompetitionLabel }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="merge-pools-actions">
|
||||
<button @click="requestMergePools" :disabled="!mergeSourceClassId || !mergeTargetClassId || String(mergeSourceClassId)===String(mergeTargetClassId)">
|
||||
{{ $t('tournaments.apply') || 'Übernehmen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="groups.length" class="groups-overview">
|
||||
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merge-pools-box {
|
||||
margin: 1rem 0 0 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.merge-pools-box h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
.merge-pools-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.merge-pools-row select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.merge-pools-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
@create-matches="createMatches()"
|
||||
@highlight-match="highlightMatch"
|
||||
@go-to-match="goToMatch"
|
||||
@merge-pools="mergePools"
|
||||
/>
|
||||
|
||||
<!-- Tab: Ergebnisse -->
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user