feat(tournament): implement multi-stage tournament support with intermediate and final stages

- Added backend controller for tournament stages with endpoints to get, upsert, and advance stages.
- Created database migration for new tables: tournament_stage and tournament_stage_advancement.
- Updated models for TournamentStage and TournamentStageAdvancement.
- Enhanced frontend components to manage tournament stages, including configuration for intermediate and final rounds.
- Implemented logic for saving and advancing tournament stages, including handling of pool rules and third place matches.
- Added error handling and loading states in the frontend for better user experience.
This commit is contained in:
Torsten Schulz (local)
2025-12-14 06:46:00 +01:00
parent e83bc250a8
commit 945ec0d48c
23 changed files with 1688 additions and 50 deletions

View File

@@ -47,11 +47,152 @@
@update:newClassGender="$emit('update:newClassGender', $event)"
@update:newClassMinBirthYear="$emit('update:newClassMinBirthYear', $event)"
/>
<div class="stage-config" style="margin-top: 1.5rem;">
<h3>Zwischenrunde & Endrunde</h3>
<div v-if="stageConfig.loading" style="opacity: 0.8;">
Lade Zwischenrunden …
</div>
<div v-else>
<p style="margin: 0.25rem 0 1rem; opacity: 0.8;">
Zwischenrunde ist optional. Wenn du sie aktivierst, gibt es danach immer eine Endrunde.
KO-Endrunde wird als <strong>ein einziges Feld</strong> erzeugt.
</p>
<div style="display: grid; gap: 0.75rem; max-width: 720px;">
<label class="checkbox-item" style="margin: 0.25rem 0;">
<input type="checkbox" v-model="stageConfig.useIntermediateStage" />
<span>Zwischenrunde verwenden</span>
</label>
<div v-if="stageConfig.useIntermediateStage" style="border: 1px solid #eee; border-radius: 6px; padding: 0.75rem;">
<strong>Zwischenrunde (Runde 2)</strong>
<label>
Runde 2 Modus:
<select v-model="stageConfig.stage2Type">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="stageConfig.stage2Type === 'groups'">
Anzahl Gruppen in Runde 2 (pro Pool):
<input type="number" min="1" v-model.number="stageConfig.stage2GroupCount" />
</label>
<div class="pool-config" style="border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem;">
<div style="display:flex; align-items:center; justify-content: space-between; gap: 1rem;">
<strong>Weiterkommen: Vorrunde → Zwischenrunde (1→2)</strong>
<button class="btn-secondary" @click="addPoolRule('12')" type="button">Pool-Regel hinzufügen</button>
</div>
<div v-if="stageConfig.pools12.length === 0" style="margin-top: 0.5rem; opacity: 0.8;">
Noch keine Regeln. Beispiel: Plätze 1 & 2 -> obere Runde-2-Gruppen.
</div>
<div v-for="(rule, idx) in stageConfig.pools12" :key="`12-${idx}`" style="display:grid; gap:0.5rem; margin-top:0.75rem; padding-top:0.75rem; border-top: 1px solid #eee;">
<label>
Plätze aus jeder Gruppe (z.B. 1,2):
<input type="text" v-model="rule.fromPlacesText" />
</label>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap; align-items: end;">
<label>
Ziel:
<select v-model="rule.targetType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="rule.targetType === 'groups'">
Ziel-Gruppenanzahl:
<input type="number" min="1" v-model.number="rule.targetGroupCount" />
</label>
<button class="btn-danger" type="button" @click="removePoolRule(idx)">Entfernen</button>
</div>
</div>
</div>
</div>
<div style="border: 1px solid #eee; border-radius: 6px; padding: 0.75rem;">
<strong>Endrunde (Runde {{ stageConfig.useIntermediateStage ? 3 : 2 }})</strong>
<label style="margin-top: 0.5rem;">
Endrunde Modus:
<select v-model="stageConfig.finalStageType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="stageConfig.finalStageType === 'knockout'" style="margin-top: 0.5rem; display:block;">
<input
type="checkbox"
:checked="stageConfig.finalStageThirdPlace"
@change="onThirdPlaceToggle($event.target.checked)"
@click.stop
/>
Platz 3 ausspielen
</label>
<label v-if="stageConfig.finalStageType === 'groups'">
Anzahl Gruppen in Endrunde:
<input type="number" min="1" v-model.number="stageConfig.finalStageGroupCount" />
</label>
<div class="pool-config" style="border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem; margin-top: 0.75rem;">
<div style="display:flex; align-items:center; justify-content: space-between; gap: 1rem;">
<strong>Weiterkommen: {{ stageConfig.useIntermediateStage ? 'Zwischenrunde → Endrunde (2→3)' : 'Vorrunde → Endrunde (1→3)' }}</strong>
<button class="btn-secondary" @click="addPoolRule('final')" type="button">Pool-Regel hinzufügen</button>
</div>
<div v-if="finalPools.length === 0" style="margin-top: 0.5rem; opacity: 0.8;">
Beispiel: Plätze 1 & 2 -> Endrunde.
</div>
<div v-for="(rule, idx) in finalPools" :key="`final-${idx}`" style="display:grid; gap:0.5rem; margin-top:0.75rem; padding-top:0.75rem; border-top: 1px solid #eee;">
<label>
Plätze aus jeder Gruppe (z.B. 1,2):
<input type="text" v-model="rule.fromPlacesText" />
</label>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap; align-items: end;">
<label>
Ziel:
<select v-model="rule.targetType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="rule.targetType === 'groups'">
Ziel-Gruppenanzahl:
<input type="number" min="1" v-model.number="rule.targetGroupCount" />
</label>
<button class="btn-danger" type="button" @click="removePoolRule(idx, 'final')">Entfernen</button>
</div>
</div>
</div>
</div>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap;">
<button class="btn-primary" type="button" @click="onSaveClick">Runden speichern</button>
<button v-if="!stageConfig.useIntermediateStage" class="btn-secondary" type="button" @click="advanceStage(1, 3)">
Endrunde aus Vorrunde erstellen
</button>
<button v-else class="btn-secondary" type="button" @click="advanceStage(1, 2)">
Zwischenrunde aus Vorrunde erstellen
</button>
<button v-if="stageConfig.useIntermediateStage" class="btn-secondary" type="button" @click="advanceStage(2, 3)">
Endrunde aus Zwischenrunde erstellen
</button>
</div>
<div v-if="stageConfig.error" style="color: #b00020; white-space: pre-wrap;">
{{ stageConfig.error }}
</div>
<div v-if="stageConfig.success" style="color: #1b5e20;">
{{ stageConfig.success }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TournamentClassList from './TournamentClassList.vue';
import apiClient from '../../apiClient';
export default {
name: 'TournamentConfigTab',
@@ -59,6 +200,14 @@ export default {
TournamentClassList
},
props: {
clubId: {
type: [Number, String],
required: true
},
tournamentId: {
type: [Number, String],
required: true
},
tournamentName: {
type: String,
required: true
@@ -120,6 +269,36 @@ export default {
default: null
}
},
data() {
return {
stageConfig: {
loading: false,
useIntermediateStage: true,
stage2Type: 'groups',
stage2GroupCount: 2,
pools12: [],
poolsFinal: [],
finalStageType: 'knockout',
finalStageThirdPlace: false,
finalStageGroupCount: 1,
error: null,
success: null,
},
};
},
computed: {
finalPools() {
return this.stageConfig.poolsFinal;
}
},
watch: {
tournamentId: {
immediate: true,
handler() {
this.loadStageConfig();
}
}
},
emits: [
'update:tournamentName',
'update:tournamentDate',
@@ -142,6 +321,336 @@ export default {
'update:newClassGender',
'update:newClassMinBirthYear'
]
,
methods: {
onSaveClick() {
this.saveStageConfig();
},
async onThirdPlaceToggle(checked) {
// UI sofort aktualisieren
this.stageConfig.finalStageThirdPlace = checked === true;
this.stageConfig.error = null;
this.stageConfig.success = null;
if (!this.clubId || !this.tournamentId) {
this.stageConfig.error = 'Kann nicht speichern: clubId oder tournamentId fehlt.';
return;
}
// Unabhängig von Pool-Regeln speichern:
// wir laden die aktuelle Stage-Konfiguration und patchen nur target.thirdPlace.
try {
const getRes = await apiClient.get('/tournament/stages', {
params: {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
}
});
if (getRes.status >= 400) throw new Error(getRes.data?.error || 'Fehler beim Laden');
if (!Array.isArray(getRes.data?.stages) || !Array.isArray(getRes.data?.advancements)) {
throw new Error('Fehlerhafte Antwort vom Server (stages/advancements fehlen).');
}
let stages = Array.isArray(getRes.data?.stages) ? getRes.data.stages : [];
let advancements = Array.isArray(getRes.data?.advancements) ? getRes.data.advancements : [];
// Wenn noch keine Konfiguration existiert, können wir das Flag nicht "isoliert" speichern.
// Dann erzeugen wir eine minimale Stage+Advancement-Konfiguration (mit Default-Poolregel),
// damit thirdPlace überhaupt persistiert werden kann.
if (stages.length === 0 && advancements.length === 0) {
const built = this.buildPayload();
stages = built.stages;
advancements = built.advancements;
// Falls der Nutzer noch keine Pool-Regeln angelegt hat, legen wir eine Default-Regel an,
// damit der Backend-Validator nicht abbricht.
for (const adv of advancements) {
const pools = adv?.config?.pools;
if (!Array.isArray(pools) || pools.length === 0) {
adv.config = adv.config || {};
adv.config.pools = [{
fromPlaces: [1, 2],
target: { type: 'knockout', singleField: true, thirdPlace: checked === true },
}];
}
}
}
const patchedAdvancements = advancements.map(a => {
// Wenn wir gerade initial erzeugen (adv hat fromStageIndex/toStageIndex), patchen wir direkt.
if (a && a.fromStageIndex != null && a.toStageIndex != null) {
const isFinal = (Number(a.fromStageIndex) === 1 && Number(a.toStageIndex) === 3)
|| (Number(a.fromStageIndex) === 2 && Number(a.toStageIndex) === 3);
if (!isFinal) return a;
const cfg = a?.config && typeof a.config === 'object' ? a.config : {};
const pools = Array.isArray(cfg.pools) ? cfg.pools : [];
return {
...a,
config: {
...cfg,
pools: pools.map(p => {
const target = p?.target && typeof p.target === 'object' ? p.target : {};
if (target.type === 'knockout') {
return { ...p, target: { ...target, thirdPlace: checked === true } };
}
return p;
})
}
};
}
// Existing DB-shape: patch nur Final-Übergang (1->3 / 2->3) via stageId mapping
// Nur Final-Übergang patchen: (1->3) oder (2->3)
const stageById = new Map(stages.map(s => [Number(s.id), s]));
const fromIdx = Number(stageById.get(Number(a?.fromStageId))?.index);
const toIdx = Number(stageById.get(Number(a?.toStageId))?.index);
const isFinal = (fromIdx === 1 && toIdx === 3) || (fromIdx === 2 && toIdx === 3);
if (!isFinal) return a;
const cfg = a?.config && typeof a.config === 'object' ? a.config : {};
const pools = Array.isArray(cfg.pools) ? cfg.pools : [];
return {
...a,
config: {
...cfg,
pools: pools.map(p => {
const target = p?.target && typeof p.target === 'object' ? p.target : {};
if (target.type === 'knockout') {
return { ...p, target: { ...target, thirdPlace: checked === true } };
}
return p;
})
}
};
});
const putRes = await apiClient.put('/tournament/stages', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
stages,
advancements: patchedAdvancements,
});
if (putRes.status >= 400) throw new Error(putRes.data?.error || 'Fehler beim Speichern');
await this.loadStageConfig();
this.stageConfig.success = 'Gespeichert.';
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
},
async loadStageConfig() {
if (!this.clubId || !this.tournamentId) return;
this.stageConfig.loading = true;
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const res = await apiClient.get('/tournament/stages', {
params: {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
}
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Laden der Zwischenrunden');
const stages = Array.isArray(res.data?.stages) ? res.data.stages : [];
const advancements = Array.isArray(res.data?.advancements) ? res.data.advancements : [];
// Zwischenrunde optional: wenn Stage 2 fehlt, gehen wir von direkter Endrunde aus.
const stage2 = stages.find(s => Number(s.index) === 2);
this.stageConfig.useIntermediateStage = !!stage2;
if (stage2) {
this.stageConfig.stage2Type = stage2.type || 'groups';
this.stageConfig.stage2GroupCount = stage2.numberOfGroups || 2;
}
const stage3 = stages.find(s => Number(s.index) === 3);
if (stage3) {
this.stageConfig.finalStageType = stage3.type || 'knockout';
this.stageConfig.finalStageGroupCount = stage3.numberOfGroups || 1;
} else {
// Fallback, wenn bisher nur 1->2 existierte
this.stageConfig.finalStageType = 'knockout';
this.stageConfig.finalStageGroupCount = 1;
}
const adv12 = advancements.find(a => Number(a?.fromStageId) && Number(a?.toStageId) && Number(stages.find(s => s.id === a.fromStageId)?.index) === 1 && Number(stages.find(s => s.id === a.toStageId)?.index) === 2) || null;
const advFinal = advancements.find(a => {
const fromIdx = Number(stages.find(s => s.id === a.fromStageId)?.index);
const toIdx = Number(stages.find(s => s.id === a.toStageId)?.index);
return (fromIdx === 1 && toIdx === 3) || (fromIdx === 2 && toIdx === 3);
}) || null;
const pools12 = Array.isArray(adv12?.config?.pools) ? adv12.config.pools : [];
this.stageConfig.pools12 = pools12.map(p => ({
fromPlacesText: Array.isArray(p.fromPlaces) ? p.fromPlaces.join(',') : '',
targetType: p?.target?.type || 'groups',
targetGroupCount: p?.target?.groupCount || this.stageConfig.stage2GroupCount || 2,
}));
const poolsFinal = Array.isArray(advFinal?.config?.pools) ? advFinal.config.pools : [];
this.stageConfig.poolsFinal = poolsFinal.map(p => ({
fromPlacesText: Array.isArray(p.fromPlaces) ? p.fromPlaces.join(',') : '',
targetType: p?.target?.type || this.stageConfig.finalStageType || 'knockout',
targetGroupCount: p?.target?.groupCount || this.stageConfig.finalStageGroupCount || 1,
}));
// KO-Flag gilt für die gesamte Endrunde: true, sobald irgendeine Final-KO-Regel thirdPlace=true hat.
this.stageConfig.finalStageThirdPlace = poolsFinal.some(p => p?.target?.type === 'knockout' && p?.target?.thirdPlace === true);
} catch (e) {
this.stageConfig.error = e?.message || String(e);
} finally {
this.stageConfig.loading = false;
}
},
addPoolRule(which = '12') {
const isFinal = which === 'final';
const targetArray = isFinal ? this.stageConfig.poolsFinal : this.stageConfig.pools12;
targetArray.push({
fromPlacesText: '1,2',
targetType: 'groups',
targetGroupCount: isFinal
? (this.stageConfig.finalStageGroupCount || 1)
: (this.stageConfig.stage2GroupCount || 2),
});
},
removePoolRule(idx, which = '12') {
const targetArray = (which === 'final') ? this.stageConfig.poolsFinal : this.stageConfig.pools12;
targetArray.splice(idx, 1);
},
buildPoolsPayload(rules, defaultGroupCount, knockoutSingleField = false, knockoutThirdPlace = false) {
return (rules || [])
.map(r => {
const fromPlaces = String(r.fromPlacesText || '')
.split(',')
.map(x => Number(String(x).trim()))
.filter(n => Number.isFinite(n) && n > 0);
return {
fromPlaces,
target: r.targetType === 'knockout'
? { type: 'knockout', singleField: knockoutSingleField, thirdPlace: knockoutThirdPlace }
: { type: 'groups', groupCount: Math.max(1, Number(r.targetGroupCount || defaultGroupCount || 1)) }
};
})
.filter(p => p.fromPlaces.length > 0);
},
buildPayload() {
const pools12 = this.stageConfig.useIntermediateStage
? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false)
: [];
const poolsFinal = this.buildPoolsPayload(
this.stageConfig.poolsFinal,
this.stageConfig.finalStageGroupCount || 1,
true,
this.stageConfig.finalStageThirdPlace === true
);
const stages = [
{ index: 1, type: 'groups', name: 'Vorrunde' },
];
const advancements = [];
if (this.stageConfig.useIntermediateStage) {
stages.push({
index: 2,
type: this.stageConfig.stage2Type,
name: 'Zwischenrunde',
numberOfGroups: this.stageConfig.stage2Type === 'groups'
? Math.max(1, Number(this.stageConfig.stage2GroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 1,
toStageIndex: 2,
mode: 'pools',
config: { pools: pools12 },
});
stages.push({
index: 3,
type: this.stageConfig.finalStageType,
name: 'Endrunde',
numberOfGroups: this.stageConfig.finalStageType === 'groups'
? Math.max(1, Number(this.stageConfig.finalStageGroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 2,
toStageIndex: 3,
mode: 'pools',
config: { pools: poolsFinal },
});
} else {
stages.push({
index: 3,
type: this.stageConfig.finalStageType,
name: 'Endrunde',
numberOfGroups: this.stageConfig.finalStageType === 'groups'
? Math.max(1, Number(this.stageConfig.finalStageGroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 1,
toStageIndex: 3,
mode: 'pools',
config: { pools: poolsFinal },
});
}
return { stages, advancements };
},
async saveStageConfig() {
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const { stages, advancements } = this.buildPayload();
// Validierung: Für jeden Übergang müssen Pools vorhanden sein
for (const adv of advancements) {
const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0;
if (!hasPools) {
const label = `${adv.fromStageIndex}${adv.toStageIndex}`;
throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`);
}
}
const res = await apiClient.put('/tournament/stages', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
stages,
advancements,
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Speichern');
await this.loadStageConfig();
this.stageConfig.success = 'Gespeichert.';
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
},
async advanceStage(fromStageIndex, toStageIndex) {
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const res = await apiClient.post('/tournament/stages/advance', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
fromStageIndex: Number(fromStageIndex),
toStageIndex: Number(toStageIndex),
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde');
this.stageConfig.success = `Runde ${toStageIndex} wurde erstellt.`;
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
}
}
};
</script>

View File

@@ -58,7 +58,7 @@
<button @click="$emit('randomize-groups')">{{ $t('tournaments.randomizeGroups') }}</button>
<button @click="$emit('reset-groups')">{{ $t('tournaments.resetGroups') }}</button>
</section>
<section v-if="groups.length" class="groups-overview">
<section v-if="groups.length" class="groups-overview">
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
<template v-for="(classGroups, classId) in groupsByClass" :key="classId">
<template v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))">
@@ -116,7 +116,12 @@
</div>
</template>
</template>
<div class="reset-controls" style="margin-top:1rem">
<div v-if="!matches.some(m => m.round === 'group')" 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">
<button @click="$emit('reset-matches')" class="trash-btn">
🗑 {{ $t('tournaments.resetGroupMatches') }}
</button>
@@ -199,6 +204,7 @@ export default {
'randomize-groups',
'reset-groups',
'reset-matches',
'create-matches',
'highlight-match'
],
methods: {

View File

@@ -356,6 +356,7 @@ export default {
};
},
getPlayerName(p) {
if (!p) return 'TBD';
if (p.member) {
return p.member.firstName + ' ' + p.member.lastName;
} else {

View File

@@ -71,6 +71,8 @@
<!-- Tab: Konfiguration -->
<TournamentConfigTab
v-if="activeTab === 'config'"
:club-id="currentClub"
:tournament-id="selectedDate"
:tournament-name="currentTournamentName"
:tournament-date="currentTournamentDate"
:winning-sets="currentWinningSets"
@@ -173,6 +175,7 @@
@randomize-groups="randomizeGroups()"
@reset-groups="resetGroups()"
@reset-matches="resetMatches()"
@create-matches="startMatches()"
@highlight-match="highlightMatch"
/>
@@ -371,6 +374,7 @@ export default {
if (roundName.includes('Achtelfinale')) return 0;
if (roundName.includes('Viertelfinale')) return 1;
if (roundName.includes('Halbfinale')) return 2;
if (roundName.includes('Spiel um Platz 3')) return 2.5;
if (roundName.includes('Finale')) return 3;
// Für Runden wie "6-Runde", "8-Runde" etc. - extrahiere die Zahl
const numberMatch = roundName.match(/(\d+)-Runde/);