feat(tournament): add number of tables feature and update related logic

- Introduced a new field `numberOfTables` in the Tournament model to track the number of tables for tournaments.
- Updated the tournament update logic to include `numberOfTables` when modifying tournament details.
- Added a new endpoint to set the table number for matches, enhancing match management.
- Updated frontend components to support the new `numberOfTables` feature, including input fields and table distribution logic.
- Enhanced internationalization with new translation keys for table-related features.
This commit is contained in:
Torsten Schulz (local)
2026-02-06 15:12:05 +01:00
parent 1191636d92
commit 566361e46a
14 changed files with 352 additions and 3139 deletions

View File

@@ -17,7 +17,8 @@
<div class="info-message">
{{ message }}
</div>
<div v-if="details" class="info-details">
<div v-if="detailsHtml" class="info-details" v-html="detailsHtml"></div>
<div v-else-if="details" class="info-details">
{{ details }}
</div>
<div v-if="$slots.default" class="info-extra">
@@ -62,6 +63,10 @@ export default {
type: String,
default: ''
},
detailsHtml: {
type: String,
default: ''
},
type: {
type: String,
default: 'info',
@@ -175,6 +180,15 @@ export default {
line-height: 1.5;
}
.info-details :deep(table) {
color: #000;
text-align: left;
}
.info-details :deep(tr:nth-child(even)) {
background-color: #f8f9fa;
}
.info-extra {
margin-top: 1rem;
padding-top: 1rem;

View File

@@ -13,6 +13,10 @@
{{ $t('tournaments.winningSets') }}:
<input type="number" :value="winningSets" @input="$emit('update:winningSets', parseInt($event.target.value))" min="1" />
</label>
<label>
{{ $t('tournaments.numberOfTables') }}:
<input type="number" :value="numberOfTables" @input="$emit('update:numberOfTables', $event.target.value === '' ? null : parseInt($event.target.value))" min="1" placeholder="" />
</label>
<button @click="$emit('generate-pdf')" class="btn-primary" style="margin-top: 1rem;">{{ $t('tournaments.exportPDF') }}</button>
</div>
<label class="checkbox-item">
@@ -220,6 +224,10 @@ export default {
type: Number,
required: true
},
numberOfTables: {
type: [Number, null],
default: null
},
isGroupTournament: {
type: Boolean,
required: true
@@ -307,6 +315,7 @@ export default {
'update:tournamentName',
'update:tournamentDate',
'update:winningSets',
'update:numberOfTables',
'update:isGroupTournament',
'generate-pdf',
'edit-class',

View File

@@ -7,6 +7,11 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<div v-if="numberOfTables && (filteredGroupMatches.length || filteredKnockoutMatches.length)" class="distribute-tables-bar">
<button @click="$emit('distribute-tables')" class="btn-primary">
{{ $t('tournaments.distributeTables') }}
</button>
</div>
<section v-if="filteredGroupMatches.length" class="group-matches">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<table>
@@ -14,6 +19,7 @@
<tr>
<th>{{ $t('tournaments.round') }}</th>
<th>{{ $t('tournaments.group') }}</th>
<th v-if="numberOfTables">{{ $t('tournaments.table') }}</th>
<th>{{ $t('tournaments.encounter') }}</th>
<th>{{ $t('tournaments.result') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
@@ -31,6 +37,22 @@
{{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
</template>
</td>
<td v-if="numberOfTables">
<select
:value="m.tableNumber || ''"
@change="$emit('set-match-table', m, $event.target.value === '' ? null : parseInt($event.target.value))"
class="table-select"
:disabled="m.isFinished"
>
<option value=""></option>
<option
v-for="t in numberOfTables"
:key="t"
:value="t"
:disabled="occupiedTables.has(t) && m.tableNumber !== t"
>{{ t }}{{ occupiedTables.has(t) && m.tableNumber !== t ? ' ●' : '' }}</option>
</select>
</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
@@ -126,6 +148,7 @@
<tr>
<th>{{ $t('tournaments.class') }}</th>
<th>{{ $t('tournaments.round') }}</th>
<th v-if="numberOfTables">{{ $t('tournaments.table') }}</th>
<th>{{ $t('tournaments.encounter') }}</th>
<th>{{ $t('tournaments.result') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
@@ -136,6 +159,22 @@
<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 v-if="numberOfTables">
<select
:value="m.tableNumber || ''"
@change="$emit('set-match-table', m, $event.target.value === '' ? null : parseInt($event.target.value))"
class="table-select"
:disabled="m.isFinished"
>
<option value=""></option>
<option
v-for="t in numberOfTables"
:key="t"
:value="t"
:disabled="occupiedTables.has(t) && m.tableNumber !== t"
>{{ t }}{{ occupiedTables.has(t) && m.tableNumber !== t ? ' ●' : '' }}</option>
</select>
</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
@@ -296,6 +335,10 @@ export default {
pairings: {
type: Array,
required: true
},
numberOfTables: {
type: [Number, null],
default: null
}
},
computed: {
@@ -330,6 +373,17 @@ export default {
}
return result;
},
occupiedTables() {
// Tische, die von laufenden (nicht abgeschlossenen) Matches belegt sind
const allMatches = [...this.groupMatches, ...this.knockoutMatches];
const occupied = new Set();
for (const m of allMatches) {
if (m.tableNumber && !m.isFinished) {
occupied.add(m.tableNumber);
}
}
return occupied;
},
numberOfGroupsForSelectedClass() {
// Zähle direkt die Gruppen für die ausgewählte Klasse
// Nur Stage 1 Gruppen (stageId null/undefined) zählen
@@ -374,6 +428,8 @@ export default {
'finish-match',
'reopen-match',
'set-match-active',
'set-match-table',
'distribute-tables',
'start-matches',
'start-knockout',
'reset-knockout'
@@ -583,4 +639,17 @@ export default {
.active-match:hover {
background-color: #ffe69c !important;
}
.distribute-tables-bar {
margin-bottom: 1rem;
}
.table-select {
width: 3.5rem;
padding: 0.15rem 0.25rem;
font-size: 0.85rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #fff;
}
</style>

View File

@@ -574,6 +574,13 @@
"name": "Name",
"tournamentName": "Turniername",
"winningSets": "Gewinnsätze",
"numberOfTables": "Anzahl Tische",
"table": "Tisch",
"distributeTables": "Freie Tische verteilen",
"distributeTablesResult": "Tischverteilung",
"noFreeTables": "Keine freien Tische verfügbar.",
"noAssignableMatches": "Keine Spiele verfügbar, bei denen beide Spieler frei sind.",
"tablesDistributed": "Tische wurden verteilt.",
"create": "Erstellen",
"exportPDF": "PDF exportieren",
"playInGroups": "Spielen in Gruppen",

View File

@@ -98,6 +98,7 @@
:is-mini-championship="isMiniChampionship"
:tournament-date="currentTournamentDate"
:winning-sets="currentWinningSets"
:number-of-tables="currentNumberOfTables"
:is-group-tournament="isGroupTournament"
:tournament-classes="tournamentClasses"
:show-classes="showClasses"
@@ -113,6 +114,7 @@
@update:tournamentName="currentTournamentName = $event; updateTournament()"
@update:tournamentDate="currentTournamentDate = $event; updateTournament()"
@update:winningSets="currentWinningSets = $event; updateTournament()"
@update:numberOfTables="currentNumberOfTables = $event; updateTournament()"
@update:isGroupTournament="isGroupTournament = $event; onModusChange()"
@generate-pdf="generatePDF"
@edit-class="editClass"
@@ -223,6 +225,7 @@
:participants="participants"
:groups="groups"
:pairings="pairings"
:number-of-tables="currentNumberOfTables"
@update:selectedViewClass="selectedViewClass = $event"
@update:activeMatchId="activeMatchId = $event"
@update:editingResult="editingResult = $event"
@@ -233,6 +236,8 @@
@finish-match="finishMatch"
@reopen-match="reopenMatch"
@set-match-active="setMatchActive"
@set-match-table="setMatchTableNumber"
@distribute-tables="distributeTables"
@start-matches="startMatches"
@start-knockout="startKnockout"
@reset-knockout="resetKnockout"
@@ -263,6 +268,7 @@
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:details-html="infoDialog.detailsHtml"
:type="infoDialog.type"
/>
@@ -320,6 +326,7 @@ export default {
title: '',
message: '',
details: '',
detailsHtml: '',
type: 'info'
},
confirmDialog: {
@@ -340,6 +347,7 @@ export default {
currentTournamentName: '',
currentTournamentDate: '',
currentWinningSets: 3,
currentNumberOfTables: null,
dates: [],
participants: [],
selectedMember: null,
@@ -1153,8 +1161,8 @@ export default {
},
// Dialog Helper Methods
async showInfo(title, message, details = '', type = 'info') {
this.infoDialog = buildInfoConfig({ title, message, details, type });
async showInfo(title, message, details = '', type = 'info', detailsHtml = '') {
this.infoDialog = { ...buildInfoConfig({ title, message, details, type }), detailsHtml };
},
async showConfirm(title, message, details = '', type = 'info', options = {}) {
@@ -1220,6 +1228,7 @@ export default {
this.currentWinningSets = (tournament.winningSets != null && tournament.winningSets >= 1)
? tournament.winningSets
: defaultSets;
this.currentNumberOfTables = tournament.numberOfTables != null ? tournament.numberOfTables : null;
this.isGroupTournament = tournament.type === 'groups';
// Defensive: Backend/DB kann (historisch/UI-default) 0/null liefern.
// Für gruppenbasierte Turniere ohne Klassen brauchen wir hier aber eine sinnvolle Zahl,
@@ -1585,7 +1594,8 @@ export default {
await apiClient.put(`/tournament/${this.currentClub}/${this.selectedDate}`, {
name: this.currentTournamentName || this.currentTournamentDate,
date: this.currentTournamentDate,
winningSets: this.currentWinningSets
winningSets: this.currentWinningSets,
numberOfTables: this.currentNumberOfTables
});
// Prüfe, ob es einen Trainingstag für das neue Datum gibt
await this.checkTrainingForDate(this.currentTournamentDate);
@@ -1842,6 +1852,145 @@ export default {
}
},
async setMatchTableNumber(match, tableNumber) {
try {
await apiClient.put(`/tournament/match/${this.currentClub}/${this.selectedDate}/${match.id}/table`, {
tableNumber: tableNumber
});
await this.loadTournamentData();
} catch (error) {
console.error('Fehler beim Setzen der Tischnummer:', error);
await this.loadTournamentData();
}
},
async distributeTables() {
const numberOfTables = this.currentNumberOfTables;
if (!numberOfTables) return;
const allMatches = [...this.groupMatches, ...this.knockoutMatches];
// 1. Belegte Tische ermitteln (nur laufende, nicht abgeschlossene Matches)
const occupiedTables = new Set();
for (const m of allMatches) {
if (m.tableNumber && !m.isFinished) {
occupiedTables.add(m.tableNumber);
}
}
// 2. Freie Tische ermitteln
const freeTables = [];
for (let t = 1; t <= numberOfTables; t++) {
if (!occupiedTables.has(t)) {
freeTables.push(t);
}
}
if (freeTables.length === 0) {
await this.showInfo(
this.$t('tournaments.distributeTablesResult'),
this.$t('tournaments.noFreeTables'),
'', 'info'
);
return;
}
// 3. Spieler ermitteln, die gerade in einem laufenden Match sind
const busyPlayers = new Set();
for (const m of allMatches) {
if (!m.isFinished && m.isActive) {
if (m.player1Id) busyPlayers.add(m.player1Id);
if (m.player2Id) busyPlayers.add(m.player2Id);
}
}
// 4. Verfügbare Matches: nicht abgeschlossen, nicht aktiv, beide Spieler vorhanden und frei, kein Tisch zugewiesen
const assignableMatches = allMatches.filter(m =>
!m.isFinished &&
!m.isActive &&
m.player1Id && m.player2Id &&
!busyPlayers.has(m.player1Id) &&
!busyPlayers.has(m.player2Id) &&
!m.tableNumber
);
if (assignableMatches.length === 0) {
await this.showInfo(
this.$t('tournaments.distributeTablesResult'),
this.$t('tournaments.noAssignableMatches'),
'', 'info'
);
return;
}
// 5. Tische an Matches vergeben
const assignments = [];
const newBusyPlayers = new Set(busyPlayers);
for (const table of freeTables) {
// Nächstes Match finden, bei dem beide Spieler noch frei sind
const matchIdx = assignableMatches.findIndex(m =>
!newBusyPlayers.has(m.player1Id) &&
!newBusyPlayers.has(m.player2Id)
);
if (matchIdx === -1) break;
const match = assignableMatches.splice(matchIdx, 1)[0];
newBusyPlayers.add(match.player1Id);
newBusyPlayers.add(match.player2Id);
assignments.push({ match, table });
}
if (assignments.length === 0) {
await this.showInfo(
this.$t('tournaments.distributeTablesResult'),
this.$t('tournaments.noAssignableMatches'),
'', 'info'
);
return;
}
// 6. API-Aufrufe: Tisch setzen + Match als aktiv markieren
try {
for (const { match, table } of assignments) {
await apiClient.put(`/tournament/match/${this.currentClub}/${this.selectedDate}/${match.id}/table`, {
tableNumber: table
});
await apiClient.put(`/tournament/match/${this.currentClub}/${this.selectedDate}/${match.id}/active`, {
isActive: true
});
}
// 7. Daten neu laden
await this.loadTournamentData();
// 8. Dialog mit Ergebnis anzeigen
const rows = assignments.map(({ match, table }) => {
const name1 = this.getPlayerName(match.player1);
const name2 = this.getPlayerName(match.player2);
return `<tr><td style="font-weight:bold; padding:0.35rem 0.75rem;">${table}</td><td style="padding:0.35rem 0.75rem;">${name1}</td><td style="padding:0.35rem 0.75rem;">${name2}</td></tr>`;
});
const html = `<table style="margin:0.75rem auto; border-collapse:collapse; color:#000;"><thead><tr><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">Tisch</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">Spieler 1</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">Spieler 2</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
await this.showInfo(
this.$t('tournaments.distributeTablesResult'),
this.$t('tournaments.tablesDistributed'),
'',
'success',
html
);
} catch (error) {
console.error('Fehler beim Verteilen der Tische:', error);
await this.loadTournamentData();
await this.showInfo(
this.$t('messages.error'),
'Fehler beim Verteilen der Tische.',
error.message || '',
'error'
);
}
},
async generatePDF() {
if (!this.selectedDate || this.selectedDate === 'new') {
await this.showInfo('Hinweis', 'Bitte wählen Sie zuerst ein Turnier aus.', '', 'info');