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

@@ -271,9 +271,9 @@ export const getTournament = async (req, res) => {
export const updateTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, date, winningSets } = req.body;
const { name, date, winningSets, numberOfTables } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);
@@ -542,6 +542,21 @@ export const setMatchActive = async (req, res) => {
}
};
export const setMatchTableNumber = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.params;
const { tableNumber } = req.body;
try {
await tournamentService.setMatchTableNumber(token, clubId, tournamentId, matchId, tableNumber);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Tischnummer aktualisiert' });
} catch (err) {
console.error('[setMatchTableNumber] Error:', err);
res.status(500).json({ error: err.message });
}
};
// Externe Teilnehmer hinzufügen
export const addExternalParticipant = async (req, res) => {
const { authcode: token } = req.headers;

View File

@@ -0,0 +1,9 @@
-- Anzahl der Tische im Turnier
ALTER TABLE tournament
ADD COLUMN number_of_tables INT NULL DEFAULT NULL
COMMENT 'Anzahl der Tische, auf denen gespielt wird';
-- Tischnummer pro Match
ALTER TABLE tournament_match
ADD COLUMN table_number INT NULL DEFAULT NULL
COMMENT 'Tischnummer, an der das Match stattfindet';

View File

@@ -45,6 +45,12 @@ const Tournament = sequelize.define('Tournament', {
field: 'mini_championship_year',
comment: 'Jahr der Minimeisterschaft; nur gesetzt bei Minimeisterschaften'
},
numberOfTables: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Anzahl der Tische, auf denen gespielt wird'
},
}, {
underscored: true,
tableName: 'tournament',

View File

@@ -63,6 +63,12 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
type: DataTypes.STRING,
allowNull: true,
},
tableNumber: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Tischnummer, an der das Match stattfindet'
},
}, {
underscored: true,
tableName: 'tournament_match',

3147
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"description": "",
"dependencies": {
"axios": "^1.12.2",
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"csv-parser": "^3.0.0",

View File

@@ -27,6 +27,7 @@ import {
reopenMatch,
deleteKnockoutMatches,
setMatchActive,
setMatchTableNumber,
addExternalParticipant,
getExternalParticipants,
removeExternalParticipant,
@@ -76,6 +77,7 @@ router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", authenticate, reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive);
router.put('/match/:clubId/:tournamentId/:matchId/table', authenticate, setMatchTableNumber);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.post('/knockout', authenticate, startKnockout);
router.delete("/matches/knockout", authenticate, deleteKnockoutMatches);

View File

@@ -2138,8 +2138,8 @@ Ve // 2. Neues Turnier anlegen
return JSON.parse(JSON.stringify(t));
}
// Update Turnier (Name, Datum und Gewinnsätze)
async updateTournament(userToken, clubId, tournamentId, name, date, winningSets) {
// Update Turnier (Name, Datum, Gewinnsätze und Tischanzahl)
async updateTournament(userToken, clubId, tournamentId, name, date, winningSets, numberOfTables) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!tournament) {
@@ -2162,6 +2162,12 @@ Ve // 2. Neues Turnier anlegen
}
tournament.winningSets = winningSets;
}
if (numberOfTables !== undefined) {
if (numberOfTables !== null && numberOfTables < 1) {
throw new Error('Anzahl der Tische muss mindestens 1 sein');
}
tournament.numberOfTables = numberOfTables;
}
await tournament.save();
return JSON.parse(JSON.stringify(tournament));
@@ -3355,6 +3361,20 @@ Ve // 2. Neues Turnier anlegen
await match.save();
}
async setMatchTableNumber(userToken, clubId, tournamentId, matchId, tableNumber) {
await checkAccess(userToken, clubId);
const match = await TournamentMatch.findOne({
where: { id: matchId, tournamentId }
});
if (!match) {
throw new Error("Match nicht gefunden");
}
match.tableNumber = tableNumber != null && tableNumber !== '' ? Number(tableNumber) : null;
await match.save();
}
async resetKnockout(userToken, clubId, tournamentId, classId = null) {
await checkAccess(userToken, clubId);
// lösche alle Matches außer Gruppenphase

View File

@@ -1902,9 +1902,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
@@ -2727,9 +2727,9 @@
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz",
"integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -2739,7 +2739,7 @@
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
@@ -2793,9 +2793,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},

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');