diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index b6124b0e..925c59be 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -247,6 +247,35 @@ export const getTournamentMatches = async (req, res) => { } }; +// Setze Tischnummer für ein Spiel +export const setMatchTable = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId, matchId } = req.params; + const { tableNumber } = req.body; + try { + const updated = await tournamentService.setMatchTable(token, clubId, tournamentId, matchId, tableNumber); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json(updated); + } catch (error) { + console.error('[setMatchTable] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + +// Freie Tische verteilen (Batch) +export const distributeTables = async (req, res) => { + const { authcode: token } = req.headers; + const { clubId, tournamentId } = req.body; + try { + const updated = await tournamentService.distributeTables(token, clubId, tournamentId); + emitTournamentChanged(clubId, tournamentId); + res.status(200).json({ updated, message: 'Tische wurden verteilt.' }); + } catch (error) { + console.error('[distributeTables] Error:', error); + res.status(500).json({ error: error.message }); + } +}; + // 11. Satz-Ergebnis speichern export const addMatchResult = async (req, res) => { const { authcode: token } = req.headers; diff --git a/backend/models/Member.js b/backend/models/Member.js index 3cf48e2a..2d7bda58 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -201,7 +201,7 @@ const Member = sequelize.define('Member', { } }); -Member.belongsTo(Club, { as: 'club' }); -Club.hasMany(Member, { as: 'members' }); +Member.belongsTo(Club, { as: 'club', foreignKey: 'clubId', constraints: false }); +Club.hasMany(Member, { as: 'members', foreignKey: 'clubId', constraints: false }); export default Member; diff --git a/backend/models/TournamentMatch.js b/backend/models/TournamentMatch.js index f2fbb92e..176a4a83 100644 --- a/backend/models/TournamentMatch.js +++ b/backend/models/TournamentMatch.js @@ -59,6 +59,11 @@ const TournamentMatch = sequelize.define('TournamentMatch', { allowNull: false, defaultValue: false, }, + tableNumber: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'table_number', + }, result: { type: DataTypes.STRING, allowNull: true, diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index f1a409af..d555cef3 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -14,6 +14,7 @@ import { getTournamentMatches, addMatchResult, finishMatch, + setMatchTable, startKnockout, manualAssignGroups, resetGroups, @@ -39,6 +40,7 @@ import { createPairing, updatePairing, deletePairing, + distributeTables, } from '../controllers/tournamentController.js'; import { getStages, @@ -65,11 +67,13 @@ router.post('/match/result', authenticate, addMatchResult); router.delete('/match/result', authenticate, deleteMatchResult); router.post("/match/reopen", authenticate, reopenMatch); router.post('/match/finish', authenticate, finishMatch); +router.put('/match/:clubId/:tournamentId/:matchId/table', authenticate, setMatchTable); router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive); router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches); router.post('/knockout', authenticate, startKnockout); router.delete("/matches/knockout", authenticate, deleteKnockoutMatches); router.post('/groups/manual', authenticate, manualAssignGroups); +router.post('/matches/distribute', authenticate, distributeTables); router.put('/participant/group', authenticate, assignParticipantToGroup); // Muss VOR /:clubId/:tournamentId stehen! router.put('/:clubId/:tournamentId', authenticate, updateTournament); router.get('/:clubId/:tournamentId', authenticate, getTournament); diff --git a/backend/scripts/api_put_test_numberOfTables.js b/backend/scripts/api_put_test_numberOfTables.js index 18384978..75597b39 100644 --- a/backend/scripts/api_put_test_numberOfTables.js +++ b/backend/scripts/api_put_test_numberOfTables.js @@ -44,7 +44,7 @@ async function run() { console.log('[api-test] Using token for tournament', targetTournament.id, 'club', targetTournament.clubId); - const url = `http://localhost:3005/tournament/${targetTournament.clubId}/${targetTournament.id}`; + const url = `http://localhost:3005/api/tournament/${targetTournament.clubId}/${targetTournament.id}`; const payload = { name: targetTournament.name || 'Test Tournament', date: targetTournament.date, diff --git a/backend/scripts/create_token_for_user.js b/backend/scripts/create_token_for_user.js new file mode 100644 index 00000000..a10be91c --- /dev/null +++ b/backend/scripts/create_token_for_user.js @@ -0,0 +1,29 @@ +import '../config.js'; +import sequelize from '../database.js'; +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; +import UserToken from '../models/UserToken.js'; + +(async () => { + try { + await sequelize.authenticate(); + console.log('[create-token] DB connected'); + const email = process.env.TEST_TOKEN_EMAIL || 'tournamentmanager@test.de'; + const user = await User.findOne({ where: { email } }); + if (!user) { + console.error('[create-token] User not found:', email); + process.exit(2); + } + const payload = { userId: user.id }; + const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' }); + const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); + await UserToken.create({ userId: user.id, token, expiresAt }); + console.log('[create-token] TOKEN_START'); + console.log(token); + console.log('[create-token] TOKEN_END'); + process.exit(0); + } catch (err) { + console.error('[create-token] Error:', err); + process.exit(3); + } +})(); diff --git a/backend/scripts/inspect_and_fix_active_matches.js b/backend/scripts/inspect_and_fix_active_matches.js new file mode 100644 index 00000000..55e4c53c --- /dev/null +++ b/backend/scripts/inspect_and_fix_active_matches.js @@ -0,0 +1,51 @@ +import '../config.js'; +import sequelize from '../database.js'; +import TournamentMatch from '../models/TournamentMatch.js'; + +const argv = process.argv.slice(2); +if (argv.length === 0) { + console.error('Usage: node inspect_and_fix_active_matches.js [--fix-all]'); + process.exit(2); +} +const tournamentId = Number(argv[0]); +const fixAll = argv.includes('--fix-all') || argv.includes('--fix'); + +(async () => { + try { + await sequelize.authenticate(); + console.log('[inspect] DB connected'); + + const active = await TournamentMatch.findAll({ + where: { tournamentId, isActive: true }, + order: [['id', 'ASC']] + }); + + console.log(`[inspect] active matches for tournament ${tournamentId}: ${active.length}`); + for (const m of active) { + console.log(` id=${m.id} p1=${m.player1Id} p2=${m.player2Id} isFinished=${m.isFinished} table=${m.tableNumber}`); + } + + const candidates = await TournamentMatch.findAll({ + where: { tournamentId, isFinished: false, tableNumber: null }, + order: [['id', 'ASC']] + }); + console.log(`[inspect] candidate matches (not finished, no table): ${candidates.length}`); + for (const m of candidates) { + console.log(` id=${m.id} p1=${m.player1Id} p2=${m.player2Id} isActive=${m.isActive}`); + } + + if (fixAll) { + console.log('[inspect] Fix mode: resetting isActive=false for all matches in tournament'); + const [upd] = await sequelize.query('UPDATE tournament_match SET is_active = 0 WHERE tournament_id = :t', { replacements: { t: tournamentId } }); + console.log('[inspect] Raw update result:', upd); + + const nowActive = await TournamentMatch.count({ where: { tournamentId, isActive: true } }); + console.log('[inspect] Remaining active matches:', nowActive); + } + + process.exit(0); + } catch (err) { + console.error('[inspect] Error:', err); + process.exit(3); + } +})(); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 770781f6..7a88b1f1 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -2066,7 +2066,10 @@ class TournamentService { } match.isFinished = true; match.result = `${win}:${lose}`; + // Wenn ein Match abgeschlossen wird, darf es nicht mehr als aktiv gelten + match.isActive = false; await match.save(); + console.log(`[finishMatch] match ${matchId} finished, result=${match.result}, isActive set to false`); // Platz-3-Spiel (Legacy-KO ohne Stages): erst erzeugen, wenn beide Halbfinals fertig sind. // Keine Placeholders beim KO-Start. @@ -2894,6 +2897,80 @@ class TournamentService { await match.save(); } + async setMatchTable(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) ? null : Number(tableNumber); + await match.save(); + return match.toJSON ? match.toJSON() : match; + } + + async distributeTables(userToken, clubId, tournamentId) { + await checkAccess(userToken, clubId); + + const tournament = await Tournament.findByPk(tournamentId); + if (!tournament || tournament.clubId != clubId) throw new Error('Turnier nicht gefunden'); + + const numTables = tournament.numberOfTables ? Number(tournament.numberOfTables) : 0; + if (!numTables || numTables < 1) throw new Error('Anzahl der Tische nicht gesetzt'); + + console.log(`[distributeTables] clubId=${clubId}, tournamentId=${tournamentId}, numberOfTables=${numTables}`); + + // Ermittle aktuell laufende Matches, damit wir nur Spiele verteilen, bei denen beide Spieler frei sind + const activeMatches = await TournamentMatch.findAll({ where: { tournamentId, isActive: true, isFinished: false } }); + const activePlayerIds = new Set(); + activeMatches.forEach(am => { + if (am.player1Id) activePlayerIds.add(Number(am.player1Id)); + if (am.player2Id) activePlayerIds.add(Number(am.player2Id)); + }); + + console.log(`[distributeTables] activeMatches=${activeMatches.length} activePlayers=${[...activePlayerIds].slice(0,10).join(',')}`); + + // Lade nur noch die nicht abgeschlossenen, noch nicht zugewiesenen Matches + const matches = await TournamentMatch.findAll({ + where: { tournamentId, isFinished: false, tableNumber: null }, + order: [ ['group_round', 'ASC'], ['id', 'ASC'] ] + }); + + console.log(`[distributeTables] candidateMatches=${matches.length}`); + + let idx = 0; + const updated = []; + for (const m of matches) { + // Spiele mit BYE (fehlendem Spieler) überspringen + if (!m.player1Id || !m.player2Id) { + console.log(`[distributeTables] skipping match ${m.id} (BYE or missing player) p1=${m.player1Id} p2=${m.player2Id}`); + continue; + } + // Nur vergeben, wenn beide Spieler aktuell nicht in einem aktiven Match sind + if (activePlayerIds.has(Number(m.player1Id)) || activePlayerIds.has(Number(m.player2Id))) { + console.log(`[distributeTables] skipping match ${m.id} because player active p1=${m.player1Id} p2=${m.player2Id}`); + continue; + } + + const assign = (idx % numTables) + 1; + m.tableNumber = assign; + // Markiere das Spiel als gestartet (läuft) + m.isActive = true; + await m.save(); + updated.push(m.toJSON ? m.toJSON() : m); + + console.log(`[distributeTables] assigned match ${m.id} -> table ${assign}`); + + // Spieler gelten nun als aktiv - damit kein weiteres Spiel für sie zugewiesen wird + activePlayerIds.add(Number(m.player1Id)); + activePlayerIds.add(Number(m.player2Id)); + idx++; + } + + console.log(`[distributeTables] finished: assignedCount=${updated.length}`); + + return updated; + } + async resetKnockout(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); // lösche alle Matches außer Gruppenphase diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c8553768..ed238d73 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -204,7 +204,9 @@ v-model="infoDialog.isOpen" :title="infoDialog.title" :message="infoDialog.message" - :details="infoDialog.details" + :details="infoDialog.details" + :detailsHtml="infoDialog.detailsHtml" + :size="infoDialog.size" :type="infoDialog.type" /> @@ -345,7 +347,15 @@ export default { }, // Dialog Helper Methods async showInfo(title, message, details = '', type = 'info') { - this.infoDialog = buildInfoConfig({ title, message, details, type }); + // If details looks like HTML (e.g., a table), pass it as detailsHtml so InfoDialog renders it + const looksLikeHtml = typeof details === 'string' && /<\s*\w+[^>]*>/.test(details); + console.debug('[App.showInfo] looksLikeHtml=', looksLikeHtml, 'title=', title, 'message=', message, 'detailsLength=', typeof details === 'string' ? details.length : 0); + if (looksLikeHtml) { + // For HTML details (tables), use a wider dialog + this.infoDialog = buildInfoConfig({ title, message, details: '', detailsHtml: details, type, size: 'medium' }); + } else { + this.infoDialog = buildInfoConfig({ title, message, details, type }); + } }, async showConfirm(title, message, details = '', type = 'info', options = {}) { diff --git a/frontend/src/components/InfoDialog.vue b/frontend/src/components/InfoDialog.vue index 6bb56b99..b215ff03 100644 --- a/frontend/src/components/InfoDialog.vue +++ b/frontend/src/components/InfoDialog.vue @@ -180,12 +180,25 @@ export default { line-height: 1.5; } -.info-details :deep(table) { +.info-details ::v-deep table { color: #000; text-align: left; + width: 100%; + border-collapse: collapse; +} + +.info-details ::v-deep table thead th { + padding: 6px 8px; + border-bottom: 1px solid #e9ecef; + text-align: left; } -.info-details :deep(tr:nth-child(even)) { +.info-details ::v-deep table tbody td { + padding: 6px 8px; + border-bottom: 1px solid #f1f3f5; +} + +.info-details ::v-deep table tbody tr:nth-child(even) { background-color: #f8f9fa; } diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index 996dcfe3..eedd2e50 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -17,7 +17,8 @@ {{ $t('tournaments.numberOfTables') }}: - + +