Compare commits
10 Commits
5aa11151cf
...
dd0f29124c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0f29124c | ||
|
|
dc084806ab | ||
|
|
4b4c48a50f | ||
|
|
65acc9e0d5 | ||
|
|
13cd55c051 | ||
|
|
9bf37399d5 | ||
|
|
047b1801b3 | ||
|
|
945ec0d48c | ||
|
|
e83bc250a8 | ||
|
|
0c28b12978 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,4 +7,5 @@ backend/.env
|
||||
|
||||
backend/images/*
|
||||
backend/backend-debug.log
|
||||
backend/*.log
|
||||
backend/*.log
|
||||
backend/.env.local
|
||||
|
||||
@@ -171,10 +171,36 @@ export const getMemberActivities = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: explizite Zuordnungen sollen nur dann zählen, wenn
|
||||
// - der Participant keine Gruppe hat UND die Aktivität KEINE Gruppenbindung hat, oder
|
||||
// - die Aktivität keine Gruppenbindung hat, oder
|
||||
// - es eine Gruppenbindung gibt, die zur Gruppe des Participants passt.
|
||||
const filteredMemberActivities = memberActivities.filter((ma) => {
|
||||
if (!ma?.participant || !ma?.activity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const groupActivitiesForActivity = ma.activity.groupActivities || [];
|
||||
|
||||
// Participant ohne Gruppe -> nur Aktivitäten ohne Gruppenbindung zählen
|
||||
if (participantGroupId === null || participantGroupId === undefined) {
|
||||
return !groupActivitiesForActivity.length;
|
||||
}
|
||||
|
||||
// Keine Gruppenbindung -> immer zählen
|
||||
if (!groupActivitiesForActivity.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Gruppenbindung vorhanden -> nur zählen, wenn die Gruppe passt
|
||||
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
|
||||
});
|
||||
|
||||
// 3. Kombiniere beide Listen und entferne Duplikate
|
||||
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
|
||||
const explicitActivityKeys = new Set();
|
||||
memberActivities.forEach(ma => {
|
||||
filteredMemberActivities.forEach(ma => {
|
||||
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
|
||||
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
|
||||
const key = `${ma.activity.id}-${ma.participant.id}`;
|
||||
@@ -192,7 +218,7 @@ export const getMemberActivities = async (req, res) => {
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...memberActivities, ...uniqueGroupActivities];
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Group activities by name and count occurrences
|
||||
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
|
||||
@@ -323,6 +349,22 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
|
||||
limit: parseInt(limit) * 10 // Get more to filter by group
|
||||
});
|
||||
|
||||
// Siehe getMemberActivities(): nur zählen, wenn Gruppenbindung passt (oder keine existiert)
|
||||
const filteredMemberActivities = memberActivities.filter((ma) => {
|
||||
if (!ma?.participant || !ma?.activity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const groupActivitiesForActivity = ma.activity.groupActivities || [];
|
||||
|
||||
if (!groupActivitiesForActivity.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
|
||||
});
|
||||
|
||||
// 2. Get all group activities for groups the member belongs to
|
||||
const groupActivities = [];
|
||||
@@ -399,7 +441,7 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
// 3. Kombiniere beide Listen und entferne Duplikate
|
||||
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
|
||||
const explicitActivityKeys = new Set();
|
||||
memberActivities.forEach(ma => {
|
||||
filteredMemberActivities.forEach(ma => {
|
||||
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
|
||||
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
|
||||
const key = `${ma.activity.id}-${ma.participant.id}`;
|
||||
@@ -417,7 +459,7 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...memberActivities, ...uniqueGroupActivities];
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Gruppiere nach Datum
|
||||
const participationsByDate = new Map();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import tournamentService from "../services/tournamentService.js";
|
||||
import { emitTournamentChanged } from '../services/socketService.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
// 1. Alle Turniere eines Vereins
|
||||
export const getTournaments = async (req, res) => {
|
||||
@@ -12,6 +13,11 @@ export const getTournaments = async (req, res) => {
|
||||
res.status(200).json(tournaments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
res.set('x-debug-tournament-clubid', String(clubId));
|
||||
res.set('x-debug-tournament-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
@@ -36,23 +42,36 @@ export const addTournament = async (req, res) => {
|
||||
// 3. Teilnehmer hinzufügen - klassengebunden
|
||||
export const addParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, classId, participant: participantId } = req.body;
|
||||
const { clubId, classId, participant: participantId, tournamentId } = req.body;
|
||||
try {
|
||||
// Payloads:
|
||||
// - Mit Klasse (klassengebunden): { clubId, classId, participant }
|
||||
// - Ohne Klasse (turnierweit): { clubId, tournamentId, participant, classId: null }
|
||||
if (!participantId) {
|
||||
return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' });
|
||||
}
|
||||
if (!classId) {
|
||||
return res.status(400).json({ error: 'Klasse ist erforderlich' });
|
||||
// Allow adding a participant either to a specific class (classId) or to the whole tournament (no class)
|
||||
if (!classId && !tournamentId) {
|
||||
return res.status(400).json({ error: 'Klasse oder tournamentId ist erforderlich' });
|
||||
}
|
||||
await tournamentService.addParticipant(token, clubId, classId, participantId);
|
||||
// Hole tournamentId über die Klasse
|
||||
const tournamentClass = await TournamentClass.findByPk(classId);
|
||||
if (!tournamentClass) {
|
||||
return res.status(404).json({ error: 'Klasse nicht gefunden' });
|
||||
|
||||
// Pass through to service. If classId is present it will be used, otherwise the service should add the participant with classId = null for the given tournamentId
|
||||
await tournamentService.addParticipant(token, clubId, classId || null, participantId, tournamentId || null);
|
||||
|
||||
// Determine tournamentId for response and event emission
|
||||
let respTournamentId = tournamentId;
|
||||
if (classId && !respTournamentId) {
|
||||
const tournamentClass = await TournamentClass.findByPk(classId);
|
||||
if (!tournamentClass) {
|
||||
return res.status(404).json({ error: 'Klasse nicht gefunden' });
|
||||
}
|
||||
respTournamentId = tournamentClass.tournamentId;
|
||||
}
|
||||
const participants = await tournamentService.getParticipants(token, clubId, tournamentClass.tournamentId, classId);
|
||||
|
||||
// Fetch updated participants for the (optional) class or whole tournament
|
||||
const participants = await tournamentService.getParticipants(token, clubId, respTournamentId, classId || null);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentClass.tournamentId);
|
||||
if (respTournamentId) emitTournamentChanged(clubId, respTournamentId);
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
console.error('[addParticipant] Error:', error);
|
||||
@@ -93,7 +112,29 @@ export const createGroups = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, numberOfGroups } = req.body;
|
||||
try {
|
||||
await tournamentService.createGroups(token, clubId, tournamentId, numberOfGroups);
|
||||
// DEBUG: Eingehende Daten sichtbar machen (temporär)
|
||||
console.log('[tournamentController.createGroups] body:', req.body);
|
||||
console.log('[tournamentController.createGroups] types:', {
|
||||
clubId: typeof clubId,
|
||||
tournamentId: typeof tournamentId,
|
||||
numberOfGroups: typeof numberOfGroups,
|
||||
});
|
||||
|
||||
// Turniere ohne Klassen: `numberOfGroups: 0` kommt aus der UI (Default) vor.
|
||||
// Statt „nichts passiert“ normalisieren wir auf mindestens 1 Gruppe.
|
||||
let normalizedNumberOfGroups = numberOfGroups;
|
||||
if (normalizedNumberOfGroups !== undefined && normalizedNumberOfGroups !== null) {
|
||||
const n = Number(normalizedNumberOfGroups);
|
||||
console.log('[tournamentController.createGroups] parsed numberOfGroups:', n);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
return res.status(400).json({ error: 'numberOfGroups muss eine ganze Zahl >= 0 sein' });
|
||||
}
|
||||
normalizedNumberOfGroups = Math.max(1, n);
|
||||
}
|
||||
|
||||
console.log('[tournamentController.createGroups] normalizedNumberOfGroups:', normalizedNumberOfGroups);
|
||||
|
||||
await tournamentService.createGroups(token, clubId, tournamentId, normalizedNumberOfGroups);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
@@ -133,6 +174,21 @@ export const fillGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 7b. Gruppenspiele erstellen ohne Gruppenzuordnungen zu ändern
|
||||
export const createGroupMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.createGroupMatches(token, clubId, tournamentId, classId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 8. Gruppen mit ihren Teilnehmern abfragen
|
||||
export const getGroups = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -300,9 +356,9 @@ export const resetGroups = async (req, res) => {
|
||||
|
||||
export const resetMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetMatches(token, clubId, tournamentId);
|
||||
await tournamentService.resetMatches(token, clubId, tournamentId, classId || null);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
@@ -380,9 +436,9 @@ export const reopenMatch = async (req, res) => {
|
||||
|
||||
export const deleteKnockoutMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId);
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId, classId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "K.o.-Runde gelöscht" });
|
||||
|
||||
70
backend/controllers/tournamentStagesController.js
Normal file
70
backend/controllers/tournamentStagesController.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import tournamentService from '../services/tournamentService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
export const getStages = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.query;
|
||||
try {
|
||||
if (clubId == null || tournamentId == null) {
|
||||
return res.status(400).json({ error: 'clubId und tournamentId sind erforderlich.' });
|
||||
}
|
||||
const data = await tournamentService.getTournamentStages(token, Number(clubId), Number(tournamentId));
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
// Debug-Hilfe: zeigt, welche IDs tatsächlich am Endpoint ankamen (ohne sensible Daten)
|
||||
res.set('x-debug-stages-clubid', String(clubId));
|
||||
res.set('x-debug-stages-tournamentid', String(tournamentId));
|
||||
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertStages = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, stages, advancement, advancements } = req.body;
|
||||
try {
|
||||
const data = await tournamentService.upsertTournamentStages(
|
||||
token,
|
||||
Number(clubId),
|
||||
Number(tournamentId),
|
||||
stages,
|
||||
advancement,
|
||||
advancements
|
||||
);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
res.set('x-debug-stages-clubid', String(clubId));
|
||||
res.set('x-debug-stages-tournamentid', String(tournamentId));
|
||||
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const advanceStage = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, fromStageIndex, toStageIndex } = req.body;
|
||||
try {
|
||||
const data = await tournamentService.advanceTournamentStage(
|
||||
token,
|
||||
Number(clubId),
|
||||
Number(tournamentId),
|
||||
Number(fromStageIndex || 1),
|
||||
(toStageIndex == null ? null : Number(toStageIndex))
|
||||
);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
58
backend/migrations/20251213_add_tournament_stages.sql
Normal file
58
backend/migrations/20251213_add_tournament_stages.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- Adds multi-stage tournaments (rounds) support
|
||||
-- MariaDB/MySQL compatible migration (manual execution)
|
||||
|
||||
-- 1) New table: tournament_stage
|
||||
CREATE TABLE IF NOT EXISTS tournament_stage (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tournament_id INT NOT NULL,
|
||||
stage_index INT NOT NULL,
|
||||
name VARCHAR(255) NULL,
|
||||
type VARCHAR(32) NOT NULL, -- 'groups' | 'knockout'
|
||||
number_of_groups INT NULL,
|
||||
advancing_per_group INT NULL,
|
||||
max_group_size INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tournament_stage_tournament
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE INDEX idx_tournament_stage_tournament_id ON tournament_stage (tournament_id);
|
||||
CREATE UNIQUE INDEX uq_tournament_stage_tournament_id_index ON tournament_stage (tournament_id, stage_index);
|
||||
|
||||
-- 2) New table: tournament_stage_advancement
|
||||
CREATE TABLE IF NOT EXISTS tournament_stage_advancement (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tournament_id INT NOT NULL,
|
||||
from_stage_id INT NOT NULL,
|
||||
to_stage_id INT NOT NULL,
|
||||
mode VARCHAR(32) NOT NULL DEFAULT 'pools',
|
||||
config JSON NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tournament_stage_adv_tournament
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tournament_stage_adv_from
|
||||
FOREIGN KEY (from_stage_id) REFERENCES tournament_stage(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tournament_stage_adv_to
|
||||
FOREIGN KEY (to_stage_id) REFERENCES tournament_stage(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE INDEX idx_tournament_stage_adv_tournament_id ON tournament_stage_advancement (tournament_id);
|
||||
CREATE INDEX idx_tournament_stage_adv_from_stage_id ON tournament_stage_advancement (from_stage_id);
|
||||
CREATE INDEX idx_tournament_stage_adv_to_stage_id ON tournament_stage_advancement (to_stage_id);
|
||||
|
||||
-- 3) Add stage_id to tournament_group and tournament_match
|
||||
-- MariaDB has no IF NOT EXISTS for columns; run each ALTER once.
|
||||
-- If you rerun, comment out the ALTERs or check INFORMATION_SCHEMA first.
|
||||
ALTER TABLE tournament_group ADD COLUMN stage_id INT NULL;
|
||||
ALTER TABLE tournament_match ADD COLUMN stage_id INT NULL;
|
||||
|
||||
CREATE INDEX idx_tournament_group_tournament_stage ON tournament_group (tournament_id, stage_id);
|
||||
CREATE INDEX idx_tournament_match_tournament_stage ON tournament_match (tournament_id, stage_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Allow NULL placeholders for KO (e.g. "Spiel um Platz 3")
|
||||
-- MariaDB/MySQL manual migration
|
||||
--
|
||||
-- Background: We create placeholder matches with player1_id/player2_id = NULL.
|
||||
-- Some prod DBs still have NOT NULL on these columns.
|
||||
|
||||
-- 1) Make player columns nullable
|
||||
ALTER TABLE tournament_match MODIFY COLUMN player1_id INT NULL;
|
||||
ALTER TABLE tournament_match MODIFY COLUMN player2_id INT NULL;
|
||||
|
||||
-- 2) (Optional) If you have foreign keys to tournament_member/external participant IDs,
|
||||
-- ensure they also allow NULL. (Not adding here because not all installations have FKs.)
|
||||
|
||||
-- 3) Verify
|
||||
-- SHOW COLUMNS FROM tournament_match LIKE 'player1_id';
|
||||
-- SHOW COLUMNS FROM tournament_match LIKE 'player2_id';
|
||||
@@ -8,6 +8,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', {
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
stageId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
tournamentId : {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
|
||||
@@ -5,6 +5,10 @@ import Tournament from './Tournament.js';
|
||||
import TournamentGroup from './TournamentGroup.js';
|
||||
|
||||
const TournamentMatch = sequelize.define('TournamentMatch', {
|
||||
stageId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
tournamentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
@@ -39,11 +43,11 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
|
||||
},
|
||||
player1Id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
},
|
||||
player2Id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
},
|
||||
isFinished: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
46
backend/models/TournamentStage.js
Normal file
46
backend/models/TournamentStage.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const TournamentStage = sequelize.define('TournamentStage', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
tournamentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
index: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'stage_index',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false, // 'groups' | 'knockout'
|
||||
},
|
||||
numberOfGroups: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
advancingPerGroup: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
maxGroupSize: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'tournament_stage',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default TournamentStage;
|
||||
40
backend/models/TournamentStageAdvancement.js
Normal file
40
backend/models/TournamentStageAdvancement.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const TournamentStageAdvancement = sequelize.define('TournamentStageAdvancement', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
tournamentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
fromStageId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
toStageId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
mode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'pools',
|
||||
},
|
||||
config: {
|
||||
// JSON: { pools: [{ fromPlaces:[1,2], target:{ type:'groups', groupCount:2 }}, ...] }
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'tournament_stage_advancement',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default TournamentStageAdvancement;
|
||||
@@ -33,6 +33,8 @@ import TournamentMatch from './TournamentMatch.js';
|
||||
import TournamentResult from './TournamentResult.js';
|
||||
import ExternalTournamentParticipant from './ExternalTournamentParticipant.js';
|
||||
import TournamentPairing from './TournamentPairing.js';
|
||||
import TournamentStage from './TournamentStage.js';
|
||||
import TournamentStageAdvancement from './TournamentStageAdvancement.js';
|
||||
import Accident from './Accident.js';
|
||||
import UserToken from './UserToken.js';
|
||||
import OfficialTournament from './OfficialTournament.js';
|
||||
@@ -192,6 +194,13 @@ Club.hasMany(Tournament, { foreignKey: 'clubId', as: 'tournaments' });
|
||||
TournamentGroup.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournaments' });
|
||||
Tournament.hasMany(TournamentGroup, { foreignKey: 'tournamentId', as: 'tournamentGroups' });
|
||||
|
||||
// Tournament Stages
|
||||
TournamentStage.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
Tournament.hasMany(TournamentStage, { foreignKey: 'tournamentId', as: 'stages' });
|
||||
|
||||
TournamentStageAdvancement.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
Tournament.hasMany(TournamentStageAdvancement, { foreignKey: 'tournamentId', as: 'stageAdvancements' });
|
||||
|
||||
TournamentMember.belongsTo(TournamentGroup, {
|
||||
foreignKey: 'groupId',
|
||||
targetKey: 'id',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setModus,
|
||||
createGroups,
|
||||
fillGroups,
|
||||
createGroupMatches,
|
||||
getGroups,
|
||||
getTournament,
|
||||
getTournamentMatches,
|
||||
@@ -39,6 +40,11 @@ import {
|
||||
updatePairing,
|
||||
deletePairing,
|
||||
} from '../controllers/tournamentController.js';
|
||||
import {
|
||||
getStages,
|
||||
upsertStages,
|
||||
advanceStage,
|
||||
} from '../controllers/tournamentStagesController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -53,6 +59,7 @@ router.post('/matches/reset', authenticate, resetMatches);
|
||||
router.put('/groups', authenticate, createGroups);
|
||||
router.post('/groups/create', authenticate, createGroupsPerClass);
|
||||
router.post('/groups', authenticate, fillGroups);
|
||||
router.post('/matches/create', authenticate, createGroupMatches);
|
||||
router.get('/groups', authenticate, getGroups);
|
||||
router.post('/match/result', authenticate, addMatchResult);
|
||||
router.delete('/match/result', authenticate, deleteMatchResult);
|
||||
@@ -66,9 +73,6 @@ router.post('/groups/manual', authenticate, manualAssignGroups);
|
||||
router.put('/participant/group', authenticate, assignParticipantToGroup); // Muss VOR /:clubId/:tournamentId stehen!
|
||||
router.put('/:clubId/:tournamentId', authenticate, updateTournament);
|
||||
router.get('/:clubId/:tournamentId', authenticate, getTournament);
|
||||
router.get('/:clubId', authenticate, getTournaments);
|
||||
router.post('/', authenticate, addTournament);
|
||||
|
||||
// Externe Teilnehmer
|
||||
router.post('/external-participant', authenticate, addExternalParticipant);
|
||||
router.post('/external-participants', authenticate, getExternalParticipants);
|
||||
@@ -88,4 +92,13 @@ router.post('/pairing/:clubId/:tournamentId/:classId', authenticate, createPairi
|
||||
router.put('/pairing/:clubId/:tournamentId/:pairingId', authenticate, updatePairing);
|
||||
router.delete('/pairing/:clubId/:tournamentId/:pairingId', authenticate, deletePairing);
|
||||
|
||||
// Tournament Stages (mehrere Runden)
|
||||
router.get('/stages', authenticate, getStages);
|
||||
router.put('/stages', authenticate, upsertStages);
|
||||
router.post('/stages/advance', authenticate, advanceStage);
|
||||
|
||||
// Muss NACH allen festen Pfaden stehen, sonst matcht z.B. '/stages' als clubId='stages'
|
||||
router.get('/:clubId', authenticate, getTournaments);
|
||||
router.post('/', authenticate, addTournament);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { createServer } from 'http';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import sequelize from './database.js';
|
||||
import cors from 'cors';
|
||||
import { initializeSocketIO } from './services/socketService.js';
|
||||
@@ -337,10 +338,12 @@ app.use((err, req, res, next) => {
|
||||
|
||||
if (fs.existsSync(sslKeyPath) && fs.existsSync(sslCertPath)) {
|
||||
try {
|
||||
console.log('📜 Lade SSL-Zertifikate...');
|
||||
const httpsOptions = {
|
||||
key: fs.readFileSync(sslKeyPath),
|
||||
cert: fs.readFileSync(sslCertPath)
|
||||
};
|
||||
console.log('✅ SSL-Zertifikate erfolgreich geladen');
|
||||
|
||||
// Erstelle HTTPS-Server mit Express-App
|
||||
const httpsServer = https.createServer(httpsOptions, app);
|
||||
@@ -349,15 +352,18 @@ app.use((err, req, res, next) => {
|
||||
initializeSocketIO(httpsServer);
|
||||
socketIOInitialized = true;
|
||||
|
||||
httpsServer.listen(httpsPort, '0.0.0.0', () => {
|
||||
console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`);
|
||||
console.log(` Socket.IO Endpoint: https://tt-tagebuch.de:${httpsPort}/socket.io/`);
|
||||
});
|
||||
|
||||
// Error-Handling für HTTPS-Server
|
||||
// Prüfe, ob Port bereits belegt ist
|
||||
httpsServer.on('error', (err) => {
|
||||
console.error('❌ HTTPS-Server Error:', err.message);
|
||||
console.error(' Code:', err.code);
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`❌ Port ${httpsPort} ist bereits belegt!`);
|
||||
console.error(' → Möglicherweise läuft bereits ein anderer Server auf diesem Port');
|
||||
console.error(' → Prüfe mit: lsof -i :3051 oder netstat -tlnp | grep 3051');
|
||||
socketIOInitialized = false;
|
||||
} else {
|
||||
console.error('❌ HTTPS-Server Error:', err.message);
|
||||
console.error(' Code:', err.code);
|
||||
socketIOInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
httpsServer.on('clientError', (err, socket) => {
|
||||
@@ -366,14 +372,36 @@ app.use((err, req, res, next) => {
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
}
|
||||
});
|
||||
|
||||
// Starte HTTPS-Server
|
||||
httpsServer.listen(httpsPort, '0.0.0.0', () => {
|
||||
console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`);
|
||||
console.log(` Socket.IO Endpoint: https://tt-tagebuch.de:${httpsPort}/socket.io/`);
|
||||
console.log(` Prüfe mit: lsof -i :${httpsPort} oder netstat -tlnp | grep ${httpsPort}`);
|
||||
});
|
||||
|
||||
// Prüfe nach kurzer Verzögerung, ob Server wirklich läuft
|
||||
setTimeout(() => {
|
||||
if (socketIOInitialized) {
|
||||
exec(`lsof -i :${httpsPort} || netstat -tlnp 2>/dev/null | grep :${httpsPort} || echo "Port nicht gefunden"`, (error, stdout) => {
|
||||
if (stdout && !stdout.includes('Port nicht gefunden')) {
|
||||
console.log(`✅ Port ${httpsPort} ist aktiv und erreichbar`);
|
||||
} else {
|
||||
console.warn(`⚠️ Port ${httpsPort} scheint nicht aktiv zu sein - prüfe Server-Logs`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('⚠️ HTTPS-Server konnte nicht gestartet werden:', err.message);
|
||||
console.error(' Stack:', err.stack);
|
||||
console.log(' → Socket.IO läuft auf HTTP-Server (nur für Entwicklung)');
|
||||
socketIOInitialized = false;
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ SSL-Zertifikate nicht gefunden - Socket.IO läuft auf HTTP-Server (nur für Entwicklung)');
|
||||
console.log(` Erwartete Pfade: ${sslKeyPath}, ${sslCertPath}`);
|
||||
console.log(` Prüfe mit: ls -la ${sslKeyPath} ${sslCertPath}`);
|
||||
}
|
||||
|
||||
// Fallback: Socket.IO auf HTTP-Server (wenn noch nicht initialisiert)
|
||||
|
||||
@@ -24,7 +24,10 @@ class ApiLogService {
|
||||
schedulerJobType = null
|
||||
} = options;
|
||||
|
||||
const isError = statusCode >= 400;
|
||||
// Wenn kein Statuscode übergeben wurde, behandeln wir den Logeintrag als Fehler,
|
||||
// damit Request-/Response-Bodies für Debugging/Testzwecke gespeichert werden.
|
||||
// (Historisch haben Tests/Callsites logRequest ohne statusCode genutzt.)
|
||||
const isError = statusCode === null || statusCode === undefined ? true : statusCode >= 400;
|
||||
|
||||
// DSGVO-konform: Nur bei Fehlern Request/Response-Bodies loggen
|
||||
let sanitizedRequestBody = null;
|
||||
@@ -38,7 +41,8 @@ class ApiLogService {
|
||||
const requestBodyStr = typeof sanitizedRequestBody === 'string'
|
||||
? sanitizedRequestBody
|
||||
: JSON.stringify(sanitizedRequestBody);
|
||||
sanitizedRequestBody = truncateString(requestBodyStr, 2000);
|
||||
// Für Diagnosezwecke etwas großzügiger als 2000 Zeichen, aber weiterhin begrenzt.
|
||||
sanitizedRequestBody = truncateString(requestBodyStr, 64020);
|
||||
}
|
||||
|
||||
if (responseBody) {
|
||||
@@ -47,7 +51,7 @@ class ApiLogService {
|
||||
const responseBodyStr = typeof sanitizedResponseBody === 'string'
|
||||
? sanitizedResponseBody
|
||||
: JSON.stringify(sanitizedResponseBody);
|
||||
sanitizedResponseBody = truncateString(responseBodyStr, 2000);
|
||||
sanitizedResponseBody = truncateString(responseBodyStr, 64020);
|
||||
}
|
||||
}
|
||||
// Bei Erfolg: Keine Bodies loggen (Datenminimierung)
|
||||
|
||||
@@ -10,6 +10,34 @@ class SchedulerService {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
async runRatingUpdatesJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await autoUpdateRatingsService.executeAutomaticUpdates();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('rating_updates', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('rating_updates', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
async runMatchResultsFetchJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('match_results', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('match_results', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
@@ -21,31 +49,24 @@ class SchedulerService {
|
||||
|
||||
devLog('Starting scheduler service...');
|
||||
|
||||
// HINWEIS: Automatische MyTischtennis-Abrufe wurden deaktiviert
|
||||
// Die folgenden Jobs werden nicht mehr ausgeführt:
|
||||
// - Rating Updates (6:00 AM)
|
||||
// - Match Results Fetch (6:30 AM)
|
||||
|
||||
// Erstelle Dummy-Jobs, damit getStatus() weiterhin funktioniert
|
||||
// Rating Updates (6:00 AM)
|
||||
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
|
||||
devLog('[DISABLED] Rating updates job would run here (deactivated)');
|
||||
devLog('[Scheduler] Running rating updates job...');
|
||||
await this.runRatingUpdatesJob(true);
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
// Match Results Fetch (6:30 AM)
|
||||
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
|
||||
devLog('[DISABLED] Match results fetch job would run here (deactivated)');
|
||||
devLog('[Scheduler] Running match results fetch job...');
|
||||
await this.runMatchResultsFetchJob(true);
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
// Jobs werden NICHT gestartet (deaktiviert)
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
|
||||
devLog('MyTischtennis automatic fetch jobs are DISABLED');
|
||||
|
||||
this.isRunning = true;
|
||||
const now = new Date();
|
||||
@@ -53,11 +74,11 @@ class SchedulerService {
|
||||
devLog('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
|
||||
devLog(`[Scheduler] Server time: ${now.toISOString()}`);
|
||||
devLog(`[Scheduler] Timezone: Europe/Berlin`);
|
||||
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: DISABLED`);
|
||||
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: ENABLED`);
|
||||
devLog('[Scheduler] =====================================');
|
||||
|
||||
devLog('Scheduler service started successfully');
|
||||
devLog('MyTischtennis automatic fetch jobs are DISABLED');
|
||||
devLog('MyTischtennis automatic fetch jobs are ENABLED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,11 +118,8 @@ class SchedulerService {
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerRatingUpdates() {
|
||||
devLog('[DISABLED] Manual rating updates trigger called (deactivated)');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
|
||||
};
|
||||
devLog('[Scheduler] Manual rating updates trigger called');
|
||||
return await this.runRatingUpdatesJob(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +127,8 @@ class SchedulerService {
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerMatchResultsFetch() {
|
||||
devLog('[DISABLED] Manual match results fetch trigger called (deactivated)');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
|
||||
};
|
||||
devLog('[Scheduler] Manual match results fetch trigger called');
|
||||
return await this.runMatchResultsFetchJob(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,15 @@ class TeamDocumentService {
|
||||
clubTeamId: clubTeamId
|
||||
});
|
||||
|
||||
// In Sequelize wird filePath teils durch das Model-Attribut `path` (Feld-Mapping) überdeckt.
|
||||
// Für Controller/Tests stellen wir sicher, dass die Instanz das konkrete Dateisystem-Path als
|
||||
// `filePath`-Property trägt.
|
||||
try {
|
||||
teamDocument.setDataValue('filePath', filePath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return teamDocument;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.uploadDocument] - Error:', error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ describe('myTischtennisService', () => {
|
||||
|
||||
const stored = await MyTischtennis.findOne({ where: { userId } });
|
||||
expect(stored.savePassword).toBe(true);
|
||||
expect(Number(stored.clubId)).toBe(123);
|
||||
expect(clientMock.getUserProfile).toHaveBeenCalled();
|
||||
expect(clientMock.login).toHaveBeenCalledWith('user@example.com', 'pass');
|
||||
});
|
||||
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('schedulerService', () => {
|
||||
|
||||
it('triggert manuelle Updates und Fetches', async () => {
|
||||
const ratings = await schedulerService.triggerRatingUpdates();
|
||||
expect(ratings.success).toBe(true);
|
||||
expect(autoUpdateMock).toHaveBeenCalled();
|
||||
expect(ratings.success).toBe(true);
|
||||
expect(autoUpdateMock).toHaveBeenCalled();
|
||||
|
||||
const matches = await schedulerService.triggerMatchResultsFetch();
|
||||
expect(matches.success).toBe(true);
|
||||
expect(autoFetchMock).toHaveBeenCalled();
|
||||
expect(matches.success).toBe(true);
|
||||
expect(autoFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('führt geplante Jobs aus und protokolliert Ergebnisse', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('../utils/userUtils.js', async () => {
|
||||
});
|
||||
|
||||
import sequelize from '../database.js';
|
||||
import { Op } from 'sequelize';
|
||||
import '../models/index.js';
|
||||
|
||||
import tournamentService from '../services/tournamentService.js';
|
||||
@@ -16,6 +17,9 @@ import Tournament from '../models/Tournament.js';
|
||||
import TournamentGroup from '../models/TournamentGroup.js';
|
||||
import TournamentMember from '../models/TournamentMember.js';
|
||||
import TournamentMatch from '../models/TournamentMatch.js';
|
||||
import TournamentResult from '../models/TournamentResult.js';
|
||||
import TournamentStage from '../models/TournamentStage.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import Club from '../models/Club.js';
|
||||
import { createMember } from './utils/factories.js';
|
||||
|
||||
@@ -73,4 +77,630 @@ describe('tournamentService', () => {
|
||||
const matches = await tournamentService.getTournamentMatches('token', club.id, tournament.id);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('erlaubt Teilnehmer ohne Klasse', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const memberA = await createMember(club.id, {
|
||||
firstName: 'Clara',
|
||||
lastName: 'C',
|
||||
email: 'clara@example.com',
|
||||
gender: 'female',
|
||||
});
|
||||
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Sommercup', '2025-06-01');
|
||||
|
||||
// ohne Klasse: legacy-Aufruf (3. Argument = tournamentId)
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, memberA.id);
|
||||
await expect(
|
||||
tournamentService.addParticipant('token', club.id, tournament.id, memberA.id)
|
||||
).rejects.toThrow('Teilnehmer bereits hinzugefügt');
|
||||
|
||||
const participantsNoClass = await tournamentService.getParticipants('token', club.id, tournament.id, null);
|
||||
expect(participantsNoClass).toHaveLength(1);
|
||||
expect(participantsNoClass[0].classId).toBe(null);
|
||||
expect(participantsNoClass[0].clubMemberId).toBe(memberA.id);
|
||||
});
|
||||
|
||||
it('normalisiert numberOfGroups=0 auf mindestens 1 Gruppe', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Gruppen-Test', '2025-07-01');
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 0);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('legt bei numberOfGroups=4 genau 4 Gruppen an (ohne Klassen)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Gruppen-4er', '2025-08-01');
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 4);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
|
||||
expect(groups).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('verteilt bei "zufällig verteilen" möglichst gleichmäßig (Differenz <= 1)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Fill-Groups-Balanced', '2025-10-01');
|
||||
|
||||
// 10 Teilnehmer, 4 Gruppen => erwartete Größen: 3/3/2/2 (beliebige Reihenfolge)
|
||||
const members = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// createMember Factory braucht eindeutige Emails
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `P${i}`,
|
||||
lastName: 'T',
|
||||
email: `p${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
// Seeded-Balancing triggern: markiere mehrere als gesetzt
|
||||
// (wir testen hier explizit, dass diese Optimierung die Größen-Balance NICHT kaputt machen darf)
|
||||
const tmRows = await TournamentMember.findAll({ where: { tournamentId: tournament.id } });
|
||||
const seededIds = tmRows.slice(0, 5).map(r => r.id);
|
||||
await TournamentMember.update({ seeded: true }, { where: { id: { [Op.in]: seededIds } } });
|
||||
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 4, 1);
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 4);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
|
||||
expect(groups).toHaveLength(4);
|
||||
|
||||
const membersWithGroups = await TournamentMember.findAll({ where: { tournamentId: tournament.id } });
|
||||
const countsByGroupId = membersWithGroups.reduce((m, tm) => {
|
||||
m[tm.groupId] = (m[tm.groupId] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
const sizes = groups.map(g => countsByGroupId[g.id] || 0);
|
||||
const min = Math.min(...sizes);
|
||||
const max = Math.max(...sizes);
|
||||
expect(max - min).toBeLessThanOrEqual(1);
|
||||
expect(sizes.reduce((a, b) => a + b, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it('füllt beim KO mit thirdPlace=true das Platz-3-Spiel nach beiden Halbfinals', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'KO-3rd', '2025-11-01');
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `K${i}`,
|
||||
lastName: 'O',
|
||||
email: `ko3rd_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
// Wir gehen den Stage-Flow, damit stageId+groupId gesetzt ist.
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 1 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{
|
||||
fromPlaces: [1, 2, 3, 4],
|
||||
target: { type: 'knockout', singleField: true, thirdPlace: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
// Vorrunde-Gruppen+Member-Gruppenzuordnung vorbereiten.
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 1);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Endrunde (KO) erstellen
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
// Third-Place Match muss als Placeholder existieren
|
||||
const third = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(third).toBeTruthy();
|
||||
expect(third.player1Id).toBe(null);
|
||||
expect(third.player2Id).toBe(null);
|
||||
|
||||
// Beide Halbfinals abschließen
|
||||
const semis = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = semis.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
// Sieger jeweils als Player1 setzen (3 Gewinnsätze simulieren; winningSets default = 3)
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.isActive).toBe(true);
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Stage-KO: 3 Gruppen × Plätze 1,2 => 6 Qualifier (keine falschen IDs, keine Duplikate)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Stage-KO-6', '2025-11-20');
|
||||
|
||||
// Stages: Vorrunde (Groups) -> Endrunde (KO)
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 3 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{
|
||||
fromPlaces: [1, 2],
|
||||
target: { type: 'knockout', singleField: true, thirdPlace: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// 3 Gruppen anlegen
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 3);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
// Je Gruppe 2 Teilnehmer -> insgesamt 6
|
||||
const members = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `S${i}`,
|
||||
lastName: 'KO',
|
||||
email: `stage_ko6_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
// Gruppe 1
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[0].id, classId: null, groupId: groups[0].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[1].id, classId: null, groupId: groups[0].id });
|
||||
// Gruppe 2
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[2].id, classId: null, groupId: groups[1].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[3].id, classId: null, groupId: groups[1].id });
|
||||
// Gruppe 3
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[4].id, classId: null, groupId: groups[2].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[5].id, classId: null, groupId: groups[2].id });
|
||||
|
||||
// Gruppenspiele erzeugen+beenden (damit Ranking/Platz 1/2 stabil ist)
|
||||
// Wir erzeugen minimal pro Gruppe ein 1v1-Match und schließen es ab.
|
||||
for (const g of groups) {
|
||||
const [tm1, tm2] = await TournamentMember.findAll({ where: { tournamentId: tournament.id, groupId: g.id }, order: [['id', 'ASC']] });
|
||||
const gm = await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: g.id,
|
||||
classId: null,
|
||||
player1Id: tm1.id,
|
||||
player2Id: tm2.id,
|
||||
isFinished: true,
|
||||
isActive: true,
|
||||
result: '3:0',
|
||||
});
|
||||
await TournamentResult.bulkCreate([
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 1 },
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 2 },
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 3 },
|
||||
]);
|
||||
}
|
||||
|
||||
// KO-Endrunde erstellen
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id }, order: [['id', 'ASC']] });
|
||||
const round1 = stage2Matches.filter(m => String(m.round || '').includes('Viertelfinale') || String(m.round || '').includes('Achtelfinale') || String(m.round || '').includes('Halbfinale (3)'));
|
||||
|
||||
// Bei 6 Entrants muss ein 8er-Bracket entstehen => 3 Matches in der ersten Runde.
|
||||
// (Die Byes werden nicht als Matches angelegt.)
|
||||
expect(round1.length).toBe(3);
|
||||
for (const m of round1) {
|
||||
expect(m.player1Id).toBeTruthy();
|
||||
expect(m.player2Id).toBeTruthy();
|
||||
expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
|
||||
// Spieler-IDs müssen Member-IDs (clubMemberId) sein, nicht TournamentMember.id
|
||||
const memberIdSet = new Set(members.map(x => x.id));
|
||||
for (const m of round1) {
|
||||
expect(memberIdSet.has(m.player1Id)).toBe(true);
|
||||
expect(memberIdSet.has(m.player2Id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Legacy-KO: legt Platz-3 an und befüllt es nach beiden Halbfinals', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd', '2025-11-15');
|
||||
|
||||
// Legacy-KO erzeugt Platz-3 automatisch, sobald ein KO ab Halbfinale gestartet wird.
|
||||
|
||||
// Legacy-startKnockout aktiviert Qualifier-Ermittlung über Gruppen-Logik.
|
||||
// Dafür erstellen wir 2 Gruppen und spielen je 1 Match fertig.
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `L${i}`,
|
||||
lastName: 'KO',
|
||||
email: `legacy_ko3rd_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Pro Gruppe 1 Match beenden, damit Qualifier (Platz 1) ermittelt werden können.
|
||||
// Die Round-Robin-Gruppenspiele werden beim `fillGroups()` bereits angelegt.
|
||||
const groupMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: 'group' },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Beende alle Gruppenspiele, damit pro Gruppe die Top-2 zuverlässig bestimmbar sind
|
||||
for (const gm of groupMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
// Direkt KO starten (ohne Stages)
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
const semisAll = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = semisAll.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
// Beide Halbfinals beenden -> Platz-3 muss befüllt werden
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.isActive).toBe(true);
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Legacy-KO: bei ungerader Qualifier-Zahl wird ein Freilos vergeben (kein Duplikat / kein Self-Match)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-Bye', '2025-11-17');
|
||||
|
||||
// 3 Gruppen, jeweils 1 Spieler -> advancingPerGroup=1 => 3 Qualifier
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 3, 1);
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 3);
|
||||
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `B${i}`,
|
||||
lastName: 'YE',
|
||||
email: `legacy_bye_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Je Gruppe genau 1 Teilnehmer, und keine Gruppenspiele nötig (es gibt keine Paarungen)
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[0].id,
|
||||
classId: null,
|
||||
groupId: groups[0].id,
|
||||
});
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[1].id,
|
||||
classId: null,
|
||||
groupId: groups[1].id,
|
||||
});
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[2].id,
|
||||
classId: null,
|
||||
groupId: groups[2].id,
|
||||
});
|
||||
|
||||
// KO starten: Erwartung = genau 1 Match (2 Spieler) + 1 Freilos (ohne extra Match)
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
const koMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// Bei 3 Qualifiern muss GENAU EIN Halbfinale (3) existieren.
|
||||
const semi3 = koMatches.filter(m => m.round === 'Halbfinale (3)');
|
||||
expect(semi3).toHaveLength(1);
|
||||
expect(semi3[0].player1Id).toBeTruthy();
|
||||
expect(semi3[0].player2Id).toBeTruthy();
|
||||
expect(semi3[0].player1Id).not.toBe(semi3[0].player2Id);
|
||||
|
||||
// Self-match darf nirgends vorkommen.
|
||||
for (const m of koMatches) {
|
||||
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
|
||||
// Hinweis: Bei 3 Qualifiern wird im Legacy-Flow aktuell ein "Halbfinale (3)" erzeugt.
|
||||
// Ein automatisches Weitertragen des Freiloses bis in ein fertiges Finale ist nicht Teil dieses Tests.
|
||||
// Wichtig ist hier die Regression: kein Duplikat und kein Self-Match.
|
||||
|
||||
// Halbfinale beenden (soll keine kaputten Folge-Matches erzeugen)
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 3, '11:1');
|
||||
|
||||
const after = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// Egal ob ein Folge-Match entsteht oder nicht: es darf kein Self-Match geben.
|
||||
for (const m of after) {
|
||||
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
});
|
||||
|
||||
it('Stage advancement ist klassenisoliert (Zwischen-/Endrunde hängt nur von der jeweiligen Klasse ab)', async () => {
|
||||
const club = await Club.create({ name: 'Club', accessToken: 'token' });
|
||||
const tournament = await Tournament.create({
|
||||
clubId: club.id,
|
||||
name: 'Stages Multi-Class',
|
||||
date: '2025-12-14',
|
||||
type: 'groups',
|
||||
numberOfGroups: 2,
|
||||
advancingPerGroup: 1,
|
||||
winningSets: 3,
|
||||
allowsExternal: false,
|
||||
});
|
||||
|
||||
const classA = await TournamentClass.create({ tournamentId: tournament.id, name: 'A' });
|
||||
const classB = await TournamentClass.create({ tournamentId: tournament.id, name: 'B' });
|
||||
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 2 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{ fromPlaces: [1], target: { type: 'knockout', singleField: true, thirdPlace: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups.length).toBe(2);
|
||||
|
||||
// Klasse A fertig
|
||||
const memberA1 = await createMember(club.id, {
|
||||
firstName: 'A1',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_a1@example.com',
|
||||
gender: 'male',
|
||||
});
|
||||
const memberA2 = await createMember(club.id, {
|
||||
firstName: 'A2',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_a2@example.com',
|
||||
gender: 'female',
|
||||
});
|
||||
const a1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA1.id, classId: classA.id, groupId: groups[0].id });
|
||||
const a2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA2.id, classId: classA.id, groupId: groups[0].id });
|
||||
const aMatch = await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: groups[0].id,
|
||||
classId: classA.id,
|
||||
player1Id: a1.id,
|
||||
player2Id: a2.id,
|
||||
isFinished: true,
|
||||
isActive: true,
|
||||
result: '3:0',
|
||||
});
|
||||
await TournamentResult.bulkCreate([
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 1 },
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 2 },
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 3 },
|
||||
]);
|
||||
|
||||
// Klasse B unfertig
|
||||
const memberB1 = await createMember(club.id, {
|
||||
firstName: 'B1',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_b1@example.com',
|
||||
gender: 'male',
|
||||
});
|
||||
const memberB2 = await createMember(club.id, {
|
||||
firstName: 'B2',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_b2@example.com',
|
||||
gender: 'female',
|
||||
});
|
||||
const b1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB1.id, classId: classB.id, groupId: groups[1].id });
|
||||
const b2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB2.id, classId: classB.id, groupId: groups[1].id });
|
||||
await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: groups[1].id,
|
||||
classId: classB.id,
|
||||
player1Id: b1.id,
|
||||
player2Id: b2.id,
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
|
||||
expect(stage2Matches.some(m => m.classId === classB.id)).toBe(false);
|
||||
|
||||
// Und es wurden keine Stage2-Gruppen für Klasse B erzeugt.
|
||||
// (classless Container-Gruppen sind möglich – entscheidend ist, dass Klasse B nicht blockiert/vermengt wird.)
|
||||
const stage2Groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
|
||||
expect(stage2Groups.some(g => g.classId === classB.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('Legacy-KO: Platz-3 entsteht erst nach beiden Halbfinals (ohne Placeholder)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd-late', '2025-11-16');
|
||||
|
||||
// Gruppen nötig, damit startKnockout Qualifier ermitteln kann
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `LL${i}`,
|
||||
lastName: 'KO',
|
||||
email: `legacy_ko3rd_late_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Alle Gruppenspiele beenden
|
||||
const groupMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: 'group' },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
|
||||
for (const gm of groupMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
// KO starten
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
// Vor Halbfinal-Ende darf es kein Platz-3-Spiel geben
|
||||
const thirdBefore = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdBefore).toBeNull();
|
||||
|
||||
// Beide Halbfinals beenden -> dabei wird Finale erzeugt. Dabei muss jetzt auch Platz-3 wieder entstehen.
|
||||
const koMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = koMatches.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
|
||||
const finalAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Finale' }
|
||||
});
|
||||
expect(finalAfter).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,17 @@ config(); // sorgt dafür, dass process.env.JWT_SECRET geladen wird
|
||||
|
||||
export const getUserByToken = async (token) => {
|
||||
try {
|
||||
if (!token) {
|
||||
throw new HttpError('Token fehlt', 401);
|
||||
}
|
||||
|
||||
// 1. JWT validieren
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (e) {
|
||||
throw new HttpError('Unauthorized: Invalid credentials', 401);
|
||||
}
|
||||
|
||||
// 2. Token-Eintrag prüfen (existiert und nicht abgelaufen)
|
||||
const stored = await UserToken.findOne({
|
||||
@@ -21,7 +30,7 @@ export const getUserByToken = async (token) => {
|
||||
}
|
||||
});
|
||||
if (!stored) {
|
||||
throw new HttpError('Token abgelaufen oder ungültig', 401);
|
||||
throw new HttpError('Unauthorized: Invalid credentials', 401);
|
||||
}
|
||||
|
||||
// 3. User laden
|
||||
@@ -35,8 +44,8 @@ export const getUserByToken = async (token) => {
|
||||
console.error(err);
|
||||
// Falls es ein HttpError ist, einfach weiterwerfen
|
||||
if (err instanceof HttpError) throw err;
|
||||
// ansonsten pauschal „noaccess“
|
||||
throw new HttpError('noaccess', 403);
|
||||
// ansonsten: nicht maskieren, sondern als Unauthorized behandeln
|
||||
throw new HttpError('Unauthorized: Invalid credentials', 401);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,8 +68,15 @@ export const hasUserClubAccess = async (userId, clubId) => {
|
||||
export const checkAccess = async (userToken, clubId) => {
|
||||
try {
|
||||
const user = await getUserByToken(userToken);
|
||||
const hasAccess = await hasUserClubAccess(user.id, clubId);
|
||||
const hasAccess = await hasUserClubAccess(user.id, clubId);
|
||||
if (!hasAccess) {
|
||||
// Debug-Hilfe: keine Tokens loggen, nur userId/clubId.
|
||||
// (Wir loggen das immer, weil auf Servern NODE_ENV teils nicht gesetzt ist.)
|
||||
console.warn('[checkAccess] noaccess:', {
|
||||
userId: user?.id,
|
||||
clubId,
|
||||
clubIdType: typeof clubId,
|
||||
});
|
||||
throw new HttpError('noaccess', 403);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
366
frontend/src/components/tournament/PlayerDetailsDialog.vue
Normal file
366
frontend/src/components/tournament/PlayerDetailsDialog.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="playerName"
|
||||
:is-modal="true"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="!loading && playerData" class="player-details-content">
|
||||
<table class="player-details-table">
|
||||
<tbody>
|
||||
<tr v-if="playerData.name">
|
||||
<td class="label-cell">{{ $t('members.firstName') }} / {{ $t('members.lastName') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(playerData.name)" title="Kopieren">📋</button>
|
||||
{{ playerData.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="playerData.birthDate">
|
||||
<td class="label-cell">{{ $t('members.birthdate') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(formatDate(playerData.birthDate))" title="Kopieren">📋</button>
|
||||
{{ formatDate(playerData.birthDate) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="playerData.address">
|
||||
<td class="label-cell">{{ $t('tournaments.address') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(playerData.address)" title="Kopieren">📋</button>
|
||||
{{ playerData.address }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="playerData.gender && playerData.gender !== 'unknown'">
|
||||
<td class="label-cell">{{ $t('members.gender') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(formatGender(playerData.gender))" title="Kopieren">📋</button>
|
||||
{{ formatGender(playerData.gender) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="playerData.email">
|
||||
<td class="label-cell">{{ $t('members.emailAddress') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(playerData.email)" title="Kopieren">📋</button>
|
||||
{{ playerData.email }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="playerData.phone">
|
||||
<td class="label-cell">{{ $t('members.phoneNumber') }}</td>
|
||||
<td class="value-cell">
|
||||
<button class="copy-button" @click="copyToClipboard(playerData.phone)" title="Kopieren">📋</button>
|
||||
{{ playerData.phone }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!hasAnyData" class="no-data">
|
||||
{{ $t('tournaments.noPlayerDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="loading">
|
||||
{{ $t('messages.loading') }}...
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
{{ $t('tournaments.noPlayerDataAvailable') }}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from '../BaseDialog.vue';
|
||||
import apiClient from '../../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'PlayerDetailsDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
playerId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
isExternal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tournamentId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
clubId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
playerName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
playerData: null,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasAnyData() {
|
||||
if (!this.playerData) return false;
|
||||
return !!(this.playerData.name || this.playerData.birthDate || this.playerData.address ||
|
||||
this.playerData.gender || this.playerData.email || this.playerData.phone);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
console.log('[PlayerDetailsDialog] modelValue changed:', newVal, 'playerId:', this.playerId);
|
||||
if (newVal && this.playerId) {
|
||||
// Lade Daten erst, wenn der Dialog geöffnet wird
|
||||
this.loadPlayerData();
|
||||
} else {
|
||||
this.playerData = null;
|
||||
}
|
||||
},
|
||||
playerId(newVal) {
|
||||
console.log('[PlayerDetailsDialog] playerId changed:', newVal, 'modelValue:', this.modelValue);
|
||||
// Wenn der Dialog bereits geöffnet ist und die playerId sich ändert, lade neue Daten
|
||||
if (this.modelValue && newVal) {
|
||||
this.loadPlayerData();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadPlayerData() {
|
||||
console.log('[PlayerDetailsDialog] loadPlayerData called, playerId:', this.playerId, 'isExternal:', this.isExternal, 'clubId:', this.clubId);
|
||||
if (!this.playerId) {
|
||||
console.warn('[PlayerDetailsDialog] Keine playerId');
|
||||
return;
|
||||
}
|
||||
if (!this.clubId || isNaN(Number(this.clubId))) {
|
||||
console.error('[PlayerDetailsDialog] Invalid clubId:', this.clubId);
|
||||
this.playerData = {
|
||||
name: this.playerName,
|
||||
error: true
|
||||
};
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.playerData = null;
|
||||
|
||||
try {
|
||||
if (this.isExternal) {
|
||||
// Lade externe Teilnehmer-Daten
|
||||
// Lade alle externen Teilnehmer für dieses Turnier (ohne classId Filter = alle Klassen)
|
||||
const response = await apiClient.post('/tournament/external-participants', {
|
||||
clubId: Number(this.clubId),
|
||||
tournamentId: this.tournamentId,
|
||||
classId: null // null = alle externen Teilnehmer des Turniers
|
||||
});
|
||||
|
||||
const externalParticipant = Array.isArray(response.data)
|
||||
? response.data.find(p => p.id === this.playerId)
|
||||
: null;
|
||||
|
||||
if (externalParticipant) {
|
||||
this.playerData = {
|
||||
name: `${externalParticipant.firstName || ''} ${externalParticipant.lastName || ''}`.trim(),
|
||||
birthDate: externalParticipant.birthDate || null,
|
||||
address: null, // Externe Teilnehmer haben keine Adresse
|
||||
gender: externalParticipant.gender || null,
|
||||
email: null, // Externe Teilnehmer haben keine E-Mail
|
||||
phone: null // Externe Teilnehmer haben keine Telefonnummer
|
||||
};
|
||||
} else {
|
||||
this.playerData = {
|
||||
name: this.playerName,
|
||||
error: true
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Lade interne Member-Daten
|
||||
const response = await apiClient.get(`/clubmembers/get/${Number(this.clubId)}/true`);
|
||||
const member = response.data.find(m => m.id === this.playerId);
|
||||
|
||||
if (member) {
|
||||
// Formatiere Adresse
|
||||
let address = '';
|
||||
const parts = [];
|
||||
if (member.street) parts.push(member.street);
|
||||
if (member.postalCode) parts.push(member.postalCode);
|
||||
if (member.city) parts.push(member.city);
|
||||
address = parts.join(', ');
|
||||
|
||||
// Formatiere Telefonnummer
|
||||
let phone = '';
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
const phoneContacts = member.contacts
|
||||
.filter(c => c.type === 'phone')
|
||||
.map(c => c.value);
|
||||
phone = phoneContacts.join(', ');
|
||||
} else if (member.phone) {
|
||||
phone = member.phone;
|
||||
}
|
||||
|
||||
// Formatiere E-Mail
|
||||
let email = '';
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
const emailContacts = member.contacts
|
||||
.filter(c => c.type === 'email')
|
||||
.map(c => c.value);
|
||||
email = emailContacts.join(', ');
|
||||
} else if (member.email) {
|
||||
email = member.email;
|
||||
}
|
||||
|
||||
this.playerData = {
|
||||
name: `${member.firstName || ''} ${member.lastName || ''}`.trim(),
|
||||
birthDate: member.birthDate || null,
|
||||
address: address || null,
|
||||
gender: member.gender || null,
|
||||
email: email || null,
|
||||
phone: phone || null
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Spielerdaten:', error);
|
||||
this.playerData = {
|
||||
name: this.playerName,
|
||||
error: true
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
// Versuche, verschiedene Datumsformate zu parsen
|
||||
const ddmmyyyy = dateString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (ddmmyyyy) {
|
||||
return dateString; // Bereits im richtigen Format
|
||||
}
|
||||
return dateString;
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
},
|
||||
formatGender(gender) {
|
||||
if (!gender || gender === 'unknown') return '';
|
||||
const genderMap = {
|
||||
'male': this.$t('members.genderMale'),
|
||||
'female': this.$t('members.genderFemale'),
|
||||
'diverse': this.$t('members.genderDiverse')
|
||||
};
|
||||
return genderMap[gender] || gender;
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Optional: Feedback anzeigen (z.B. kurz "Kopiert!" anzeigen)
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Kopieren:', err);
|
||||
// Fallback für ältere Browser
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (fallbackErr) {
|
||||
console.error('Fallback-Kopieren fehlgeschlagen:', fallbackErr);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-details-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.player-details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.player-details-table tbody tr {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.player-details-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.player-details-table .label-cell {
|
||||
padding: 0.75rem 1rem 0.75rem 0;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
vertical-align: top;
|
||||
width: 40%;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.player-details-table .value-cell {
|
||||
padding: 0.75rem 0;
|
||||
color: #666;
|
||||
vertical-align: top;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-button:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -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" :disabled="stageConfig.finalStageType !== 'groups'">
|
||||
<option value="groups">Gruppen</option>
|
||||
<option value="knockout">KO</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="stageConfig.finalStageType === '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,364 @@ 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(',') : '',
|
||||
// final-stage Modus ist führend: wenn KO gewählt, setzen wir Ziel automatisch auf KO
|
||||
targetType: (this.stageConfig.finalStageType === 'knockout') ? 'knockout' : (p?.target?.type || 'groups'),
|
||||
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: isFinal
|
||||
? (this.stageConfig.finalStageType === 'knockout' ? 'knockout' : 'groups')
|
||||
: '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);
|
||||
// Wenn Endrunde KO ist, erzwingen wir KO als Ziel, damit man KO nicht doppelt einstellen muss.
|
||||
const forceKnockout = this.stageConfig.finalStageType === 'knockout';
|
||||
return {
|
||||
fromPlaces,
|
||||
target: (forceKnockout || 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 {
|
||||
// Lade aktuelle Stages, um passende IDs zu ermitteln
|
||||
const getRes = await apiClient.get('/tournament/stages', {
|
||||
params: {
|
||||
clubId: Number(this.clubId),
|
||||
tournamentId: Number(this.tournamentId)
|
||||
}
|
||||
});
|
||||
const stages = Array.isArray(getRes?.data?.stages) ? getRes.data.stages : [];
|
||||
const normalized = stages.map(s => ({
|
||||
stageIndex: Number(s.stageIndex ?? s.index ?? s.id),
|
||||
stageId: Number(s.id ?? s.stageId ?? s.stageIndex),
|
||||
type: s.type || s.targetType || s.target
|
||||
}));
|
||||
const from = normalized.find(s => s.stageIndex === Number(fromStageIndex));
|
||||
const to = normalized.find(s => s.stageIndex === Number(toStageIndex));
|
||||
|
||||
const payload = {
|
||||
clubId: Number(this.clubId),
|
||||
tournamentId: Number(this.tournamentId)
|
||||
};
|
||||
if (from?.stageId && to?.stageId) {
|
||||
payload.fromStageId = from.stageId;
|
||||
payload.toStageId = to.stageId;
|
||||
} else {
|
||||
payload.fromStageIndex = Number(fromStageIndex);
|
||||
payload.toStageIndex = Number(toStageIndex);
|
||||
}
|
||||
|
||||
const res = await apiClient.post('/tournament/stages/advance', payload);
|
||||
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>
|
||||
|
||||
|
||||
@@ -44,7 +44,13 @@
|
||||
<div v-else class="groups-per-class">
|
||||
<label>
|
||||
{{ $t('tournaments.numberOfGroups') }}:
|
||||
<input type="number" :value="numberOfGroups" @input="$emit('update:numberOfGroups', parseInt($event.target.value))" min="1" @change="$emit('group-count-change')" />
|
||||
<input
|
||||
type="number"
|
||||
:value="numberOfGroups"
|
||||
min="1"
|
||||
@input="$emit('update:numberOfGroups', Math.max(1, parseInt($event.target.value || '1', 10) || 1))"
|
||||
@change="$emit('group-count-change')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -52,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))">
|
||||
@@ -84,13 +90,16 @@
|
||||
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
|
||||
<td>{{ pl.position }}.</td>
|
||||
<td><span v-if="pl.seeded" class="seeded-star">★</span>{{ pl.name }}</td>
|
||||
<td>{{ pl.points }}</td>
|
||||
<td>{{ (pl.matchesWon || 0) * 2 }}:{{ (pl.matchesLost || 0) * 2 }}</td>
|
||||
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
|
||||
<td>
|
||||
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
|
||||
</td>
|
||||
<td>
|
||||
{{ pl.pointsWon }}:{{ pl.pointsLost }} ({{ (pl.pointsWon - pl.pointsLost) >= 0 ? '+' + (pl.pointsWon - pl.pointsLost) : (pl.pointsWon - pl.pointsLost) }})
|
||||
{{ Math.abs(pl.pointsWon || 0) }}:{{ Math.abs(pl.pointsLost || 0) }}
|
||||
<span v-if="(Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) !== 0" class="points-diff">
|
||||
({{ (Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) >= 0 ? '+' : '' }}{{ Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0) }})
|
||||
</span>
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
@@ -110,7 +119,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div class="reset-controls" style="margin-top:1rem">
|
||||
<div v-if="filteredGroupMatches.length === 0" class="reset-controls" style="margin-top:1rem">
|
||||
<button @click="$emit('create-matches')" class="btn-primary">
|
||||
▶️ Gruppenspiele berechnen
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredGroupMatches.length > 0" class="reset-controls" style="margin-top:1rem">
|
||||
<button @click="$emit('reset-matches')" class="trash-btn">
|
||||
🗑️ {{ $t('tournaments.resetGroupMatches') }}
|
||||
</button>
|
||||
@@ -193,9 +207,37 @@ export default {
|
||||
'randomize-groups',
|
||||
'reset-groups',
|
||||
'reset-matches',
|
||||
'create-matches',
|
||||
'highlight-match'
|
||||
],
|
||||
computed: {
|
||||
filteredGroupMatches() {
|
||||
return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group'));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterMatchesByClass(matches) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return matches;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
return matches.filter(m => m.classId === null || m.classId === undefined);
|
||||
}
|
||||
// Filtere nach der ausgewählten Klasse
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
if (Number.isNaN(selectedId)) {
|
||||
return matches;
|
||||
}
|
||||
return matches.filter(m => {
|
||||
const matchClassId = m.classId;
|
||||
if (matchClassId === null || matchClassId === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Number(matchClassId) === selectedId;
|
||||
});
|
||||
},
|
||||
shouldShowClass(classId) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
|
||||
@@ -7,63 +7,78 @@
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
|
||||
<!-- Endplatzierungen (K.O.-Runde) -->
|
||||
<section v-if="Object.keys(finalPlacementsByClass).length > 0" class="final-placements">
|
||||
<h3>{{ $t('tournaments.finalPlacements') }}</h3>
|
||||
<template v-for="(classPlacements, classId) in finalPlacementsByClass" :key="`final-${classId}`">
|
||||
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
|
||||
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
|
||||
<div v-if="isAllSelected || shouldShowClass(classId==='null'?null:Number(classId))" class="class-section">
|
||||
<h4 class="class-header">
|
||||
{{ getClassName(classId) }}
|
||||
</h4>
|
||||
<h4 v-else class="class-header">
|
||||
{{ $t('tournaments.withoutClass') }}
|
||||
<span class="class-type-badge" v-if="classId!==null" :class="{ doubles: isDoubles(classId), singles: !isDoubles(classId) }">
|
||||
{{ isDoubles(classId) ? $t('tournaments.doubles') : $t('tournaments.singles') }}
|
||||
</span>
|
||||
</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.position') }}</th>
|
||||
<th class="col-place">{{ labelPlace }}</th>
|
||||
<th>{{ $t('tournaments.player') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, entryIdx) in classPlacements" :key="`${entry.member?.id || entryIdx}-${entryIdx}`">
|
||||
<td><strong>{{ entry.position }}.</strong></td>
|
||||
<td>{{ getEntryPlayerName(entry) }}</td>
|
||||
<tr v-for="(entry, entryIdx) in classPlacements" :key="`final-${classId}-${entryIdx}`">
|
||||
<td class="col-place">{{ entry.position }}.</td>
|
||||
<td>
|
||||
<span
|
||||
class="player-name-clickable"
|
||||
@click="openPlayerDialog(entry)"
|
||||
:title="$t('tournaments.showPlayerDetails')"
|
||||
>
|
||||
{{ getEntryPlayerName(entry) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Gruppenplatzierungen -->
|
||||
<section v-if="groupPlacements.length > 0" class="group-placements">
|
||||
<h3>{{ $t('tournaments.groupPlacements') }}</h3>
|
||||
<template v-for="(classGroups, classId) in groupPlacementsByClass" :key="`group-${classId}`">
|
||||
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
|
||||
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
|
||||
<div v-if="isAllSelected || shouldShowClass(classId==='null'?null:Number(classId))" class="class-section">
|
||||
<h4 class="class-header">
|
||||
{{ getClassName(classId) }}
|
||||
<span class="class-type-badge" v-if="classId!==null" :class="{ doubles: isDoubles(classId), singles: !isDoubles(classId) }">
|
||||
{{ isDoubles(classId) ? $t('tournaments.doubles') : $t('tournaments.singles') }}
|
||||
</span>
|
||||
</h4>
|
||||
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
|
||||
<h5>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h5>
|
||||
<div class="group-table" v-for="(g, gi) in classGroups" :key="`group-${classId}-${gi}`">
|
||||
<h5>{{ $t('tournaments.group') }} {{ g.groupNumber }}</h5>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.position') }}</th>
|
||||
<th class="col-place">{{ labelPlace }}</th>
|
||||
<th>{{ $t('tournaments.player') }}</th>
|
||||
<th>{{ $t('tournaments.points') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
<th>{{ $t('tournaments.diff') }}</th>
|
||||
<th>{{ $t('tournaments.setDiff') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(pl, idx) in group.rankings" :key="pl.id">
|
||||
<td><strong>{{ pl.position }}.</strong></td>
|
||||
<td><span v-if="pl.seeded" class="seeded-star">★</span>{{ pl.name }}</td>
|
||||
<td>{{ pl.points }}</td>
|
||||
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
|
||||
<td>{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}</td>
|
||||
<tr v-for="(r, ri) in g.rankings" :key="`r-${g.groupId}-${ri}`">
|
||||
<td class="col-place">{{ r.position }}.</td>
|
||||
<td>
|
||||
<span
|
||||
class="player-name-clickable"
|
||||
@click="openPlayerDialogFromRanking(r)"
|
||||
:title="$t('tournaments.showPlayerDetails')"
|
||||
>
|
||||
{{ r.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.points }}</td>
|
||||
<td>{{ r.setsWon }}:{{ r.setsLost }}</td>
|
||||
<td>{{ r.setDiff >= 0 ? '+' + r.setDiff : r.setDiff }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -71,97 +86,377 @@
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div v-if="Object.keys(finalPlacementsByClass).length === 0 && groupPlacements.length === 0" class="no-placements">
|
||||
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Player Details Dialog -->
|
||||
<PlayerDetailsDialog
|
||||
v-model="showPlayerDialog"
|
||||
:player-id="selectedPlayerId"
|
||||
:is-external="selectedPlayerIsExternal"
|
||||
:tournament-id="selectedDate"
|
||||
:club-id="clubId"
|
||||
:player-name="selectedPlayerName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TournamentClassSelector from './TournamentClassSelector.vue';
|
||||
import PlayerDetailsDialog from './PlayerDetailsDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'TournamentPlacementsTab',
|
||||
components: {
|
||||
TournamentClassSelector
|
||||
components: {
|
||||
TournamentClassSelector,
|
||||
PlayerDetailsDialog
|
||||
},
|
||||
props: {
|
||||
selectedDate: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
selectedDate: { type: [String, Number], default: null },
|
||||
selectedViewClass: { type: [Number, String, null], default: null },
|
||||
tournamentClasses: { type: Array, required: true },
|
||||
participants: { type: Array, required: true },
|
||||
externalParticipants: { type: Array, required: true },
|
||||
pairings: { type: Array, required: true },
|
||||
groups: { type: Array, required: true },
|
||||
groupRankings: { type: Object, required: true },
|
||||
knockoutMatches: { type: Array, required: true },
|
||||
clubId: { type: [Number, String], required: true }
|
||||
},
|
||||
emits: ['update:selectedViewClass'],
|
||||
data() {
|
||||
return {
|
||||
showPlayerDialog: false,
|
||||
selectedPlayerId: null,
|
||||
selectedPlayerIsExternal: false,
|
||||
selectedPlayerName: ''
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showPlayerDialog(newVal) {
|
||||
console.log('[TournamentPlacementsTab] showPlayerDialog changed:', newVal);
|
||||
},
|
||||
selectedViewClass: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
knockoutMatches: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
groupRankings: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
groupedRankingList: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
participants: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
externalParticipants: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
pairings: {
|
||||
type: Array,
|
||||
required: true
|
||||
selectedPlayerId(newVal) {
|
||||
console.log('[TournamentPlacementsTab] selectedPlayerId changed:', newVal);
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:selectedViewClass'
|
||||
],
|
||||
computed: {
|
||||
// Flag für 'Alle Klassen'
|
||||
isAllSelected() {
|
||||
const sel = this.selectedViewClass;
|
||||
if (sel === null || sel === undefined || sel === '') return true;
|
||||
if (typeof sel === 'string') {
|
||||
const norm = sel.trim();
|
||||
if (norm === '__none__') return false;
|
||||
const knownAll = ['__all__', 'all', 'ALL', '__allClasses__', '__ALL_CLASSES__'];
|
||||
if (knownAll.includes(norm)) return true;
|
||||
const parsed = parseInt(norm);
|
||||
return !Number.isFinite(parsed); // nicht-numerischer String => 'Alle Klassen'
|
||||
}
|
||||
return false;
|
||||
},
|
||||
labelPlace() {
|
||||
const t = this.$t && this.$t('tournaments.place');
|
||||
if (t && typeof t === 'string' && t.trim().length > 0 && t !== 'tournaments.place') return t;
|
||||
return 'Platz';
|
||||
},
|
||||
finalPlacementsByClass() {
|
||||
// Verwende die bereits berechnete groupedRankingList aus TournamentTab
|
||||
// Diese enthält die korrekten Platzierungen basierend auf extendedRankingList oder rankingList
|
||||
return this.groupedRankingList;
|
||||
const byClass = {};
|
||||
const matchesByClass = {};
|
||||
(this.knockoutMatches || []).forEach(m => {
|
||||
const key = m.classId != null ? String(m.classId) : 'null';
|
||||
(matchesByClass[key] ||= []).push(m);
|
||||
});
|
||||
|
||||
const isClassDoubles = (classKey) => {
|
||||
const cid = classKey === 'null' ? null : Number(classKey);
|
||||
if (cid == null) return false;
|
||||
const c = (this.tournamentClasses || []).find(x => x.id === cid);
|
||||
return Boolean(c && c.isDoubles);
|
||||
};
|
||||
|
||||
const getPairingName = (cid, playerId) => {
|
||||
const classIdNum = cid == null ? null : Number(cid);
|
||||
// Versuche zuerst: playerId ist die Pairing-ID
|
||||
let pairing = (this.pairings || []).find(p => p.classId === classIdNum && p.id === playerId);
|
||||
if (!pairing) {
|
||||
// Fallback: playerId ist Turnier-Mitglied-ID (member1Id/2Id oder external1Id/2Id)
|
||||
pairing = (this.pairings || []).find(p =>
|
||||
p.classId === classIdNum && (
|
||||
p.member1Id === playerId || p.member2Id === playerId ||
|
||||
p.external1Id === playerId || p.external2Id === playerId
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!pairing) return null;
|
||||
const n1 = pairing.member1?.member
|
||||
? `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}`
|
||||
: pairing.external1
|
||||
? `${pairing.external1.firstName} ${pairing.external1.lastName}`
|
||||
: this.$t('tournaments.unknown');
|
||||
const n2 = pairing.member2?.member
|
||||
? `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}`
|
||||
: pairing.external2
|
||||
? `${pairing.external2.firstName} ${pairing.external2.lastName}`
|
||||
: this.$t('tournaments.unknown');
|
||||
return `${n1} / ${n2}`;
|
||||
};
|
||||
|
||||
const addEntry = (classKey, position, participantOrPlayer, opts = {}) => {
|
||||
const cid = classKey === 'null' ? null : Number(classKey);
|
||||
(byClass[classKey] ||= []);
|
||||
if (isClassDoubles(classKey)) {
|
||||
// Teilnehmer ist ein Player-Objekt aus Match: nutze dessen id zur Paarungssuche
|
||||
const playerId = opts.playerId != null
|
||||
? opts.playerId
|
||||
: (participantOrPlayer?.id || participantOrPlayer?.member?.id || null);
|
||||
const displayName = playerId != null ? getPairingName(cid, playerId) : null;
|
||||
if (!displayName) return;
|
||||
const existing = byClass[classKey].find(e => e.displayName === displayName);
|
||||
if (!existing) {
|
||||
byClass[classKey].push({ position, displayName, classId: cid });
|
||||
} else if (Number(position) < Number(existing.position)) {
|
||||
existing.position = position;
|
||||
}
|
||||
} else {
|
||||
// Einzel: wie zuvor mit member/direkten Namen
|
||||
const member = participantOrPlayer?.member
|
||||
? participantOrPlayer.member
|
||||
: (participantOrPlayer && (participantOrPlayer.firstName || participantOrPlayer.lastName)
|
||||
? { id: participantOrPlayer.id, firstName: participantOrPlayer.firstName, lastName: participantOrPlayer.lastName }
|
||||
: null);
|
||||
if (!member) return;
|
||||
const key = (member.id != null && Number.isFinite(Number(member.id)))
|
||||
? `id:${Number(member.id)}`
|
||||
: `name:${(member.firstName || '').trim()}|${(member.lastName || '').trim()}`;
|
||||
const existing = byClass[classKey].find(e => {
|
||||
const ek = (e.member?.id != null && Number.isFinite(Number(e.member.id)))
|
||||
? `id:${Number(e.member.id)}`
|
||||
: `name:${(e.member?.firstName || '').trim()}|${(e.member?.lastName || '').trim()}`;
|
||||
return ek === key;
|
||||
});
|
||||
if (!existing) {
|
||||
byClass[classKey].push({ position, member, classId: cid });
|
||||
} else if (Number(position) < Number(existing.position)) {
|
||||
existing.position = position;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseWinnerLoser = (match) => {
|
||||
if (!match || !match.isFinished) return { winner: null, loser: null };
|
||||
// BYE-Fälle
|
||||
if (String(match.result).toUpperCase() === 'BYE') {
|
||||
const winner = match.player1 || match.player2 || null;
|
||||
const loser = winner === match.player1 ? match.player2 : match.player1;
|
||||
const winnerSourceId = winner === match.player1 ? match.player1Id : match.player2Id;
|
||||
const loserSourceId = loser === match.player1 ? match.player1Id : match.player2Id;
|
||||
return { winner, loser, winnerSourceId, loserSourceId };
|
||||
}
|
||||
// Bevorzugt: aus tournamentResults die Satzgewinne ableiten
|
||||
if (Array.isArray(match.tournamentResults) && match.tournamentResults.length > 0) {
|
||||
let w1 = 0, w2 = 0;
|
||||
for (const r of match.tournamentResults) {
|
||||
const p1 = Number(r.pointsPlayer1);
|
||||
const p2 = Number(r.pointsPlayer2);
|
||||
if (Number.isFinite(p1) && Number.isFinite(p2)) {
|
||||
if (p1 > p2) w1++; else if (p2 > p1) w2++;
|
||||
}
|
||||
}
|
||||
if (w1 !== w2) {
|
||||
const winP = w1 > w2 ? match.player1 : match.player2;
|
||||
const loseP = w1 > w2 ? match.player2 : match.player1;
|
||||
const winnerSourceId = w1 > w2 ? match.player1Id : match.player2Id;
|
||||
const loserSourceId = w1 > w2 ? match.player2Id : match.player1Id;
|
||||
return { winner: winP, loser: loseP, winnerSourceId, loserSourceId };
|
||||
}
|
||||
}
|
||||
// Fallback: Ergebnis-String flexibel parsen (z.B. '5:11 0:1' -> nimm letztes 'a:b')
|
||||
if (typeof match.result === 'string') {
|
||||
const tokens = match.result.match(/-?\d+\s*:\s*-?\d+/g);
|
||||
if (tokens && tokens.length > 0) {
|
||||
const last = tokens[tokens.length - 1];
|
||||
const parts = last.split(':');
|
||||
const a = Number((parts[0] || '').trim());
|
||||
const b = Number((parts[1] || '').trim());
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) {
|
||||
const winP = a > b ? match.player1 : match.player2;
|
||||
const loseP = a > b ? match.player2 : match.player1;
|
||||
const winnerSourceId = a > b ? match.player1Id : match.player2Id;
|
||||
const loserSourceId = a > b ? match.player2Id : match.player1Id;
|
||||
return { winner: winP, loser: loseP, winnerSourceId, loserSourceId };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { winner: null, loser: null };
|
||||
};
|
||||
|
||||
Object.entries(matchesByClass).forEach(([classKey, classMatches]) => {
|
||||
if (!classMatches || classMatches.length === 0) return;
|
||||
const lower = (s) => (s || '').toLowerCase();
|
||||
const finalMatch = classMatches.find(m => lower(m.round) === 'finale');
|
||||
const thirdMatch = classMatches.find(m => lower(m.round).includes('platz 3'));
|
||||
const semifinals = classMatches.filter(m => lower(m.round).includes('halbfinale'));
|
||||
const quarterfinals = classMatches.filter(m => lower(m.round).includes('viertelfinale'));
|
||||
const round16 = classMatches.filter(m => lower(m.round).includes('achtelfinale'));
|
||||
|
||||
const f = parseWinnerLoser(finalMatch);
|
||||
if (f.winner) addEntry(classKey, 1, f.winner, { playerId: f.winnerSourceId });
|
||||
if (f.loser) addEntry(classKey, 2, f.loser, { playerId: f.loserSourceId });
|
||||
|
||||
const t = parseWinnerLoser(thirdMatch);
|
||||
if (t.winner) addEntry(classKey, 3, t.winner, { playerId: t.winnerSourceId });
|
||||
if (t.loser) addEntry(classKey, 4, t.loser, { playerId: t.loserSourceId });
|
||||
|
||||
if (!thirdMatch || !thirdMatch.isFinished) {
|
||||
semifinals.forEach(m => {
|
||||
const r = parseWinnerLoser(m);
|
||||
if (r.loser) addEntry(classKey, 3, r.loser, { playerId: r.loserSourceId });
|
||||
});
|
||||
}
|
||||
quarterfinals.forEach(m => {
|
||||
const r = parseWinnerLoser(m);
|
||||
if (r.loser) addEntry(classKey, 5, r.loser, { playerId: r.loserSourceId });
|
||||
});
|
||||
round16.forEach(m => {
|
||||
const r = parseWinnerLoser(m);
|
||||
if (r.loser) addEntry(classKey, 9, r.loser, { playerId: r.loserSourceId });
|
||||
});
|
||||
|
||||
byClass[classKey] = (byClass[classKey] || []).sort((a, b) => Number(a.position) - Number(b.position));
|
||||
});
|
||||
|
||||
// Ergänze alle weiteren Teilnehmer der Klasse (auch wenn sie die KO-Runde nicht erreicht haben)
|
||||
// Baue Teilnehmerlisten pro Klasse aus participants und externalParticipants
|
||||
const participantsByClass = {};
|
||||
(this.participants || []).forEach(p => {
|
||||
const key = p.classId != null ? String(p.classId) : 'null';
|
||||
(participantsByClass[key] ||= []).push(p);
|
||||
});
|
||||
(this.externalParticipants || []).forEach(p => {
|
||||
const key = p.classId != null ? String(p.classId) : 'null';
|
||||
(participantsByClass[key] ||= []).push(p);
|
||||
});
|
||||
|
||||
const getStableKeyForParticipant = (p) => {
|
||||
const member = p.member || p;
|
||||
if (member && member.id != null && Number.isFinite(Number(member.id))) {
|
||||
return `id:${Number(member.id)}`;
|
||||
}
|
||||
const fn = (member?.firstName || '').trim();
|
||||
const ln = (member?.lastName || '').trim();
|
||||
if (!fn && !ln) return null;
|
||||
return `name:${fn}|${ln}`;
|
||||
};
|
||||
|
||||
Object.entries(participantsByClass).forEach(([classKey, plist]) => {
|
||||
const existingKeys = new Set((byClass[classKey] || []).map(e => {
|
||||
if (e.displayName) return `pair:${classKey}:${e.displayName}`;
|
||||
if (e.member?.id != null && Number.isFinite(Number(e.member.id))) return `id:${Number(e.member.id)}`;
|
||||
const fn = (e.member?.firstName || '').trim();
|
||||
const ln = (e.member?.lastName || '').trim();
|
||||
return `name:${fn}|${ln}`;
|
||||
}));
|
||||
|
||||
const dedupSeen = new Set();
|
||||
const unique = [];
|
||||
for (const p of plist) {
|
||||
const k = getStableKeyForParticipant(p);
|
||||
if (!k || dedupSeen.has(k)) continue;
|
||||
dedupSeen.add(k);
|
||||
unique.push(p);
|
||||
}
|
||||
|
||||
const maxPos = Math.max(0, ...(byClass[classKey] || []).map(e => Number(e.position) || 0));
|
||||
let nextPos = maxPos + 1;
|
||||
|
||||
if (isClassDoubles(classKey)) {
|
||||
// Für Doppel: ergänze fehlende Paarungen pro Klasse
|
||||
const cidNum = classKey === 'null' ? null : Number(classKey);
|
||||
const classPairings = (this.pairings || []).filter(p => p.classId === cidNum);
|
||||
classPairings.forEach(pairing => {
|
||||
// Bevorzuge direkte Pairing-ID
|
||||
const displayName = getPairingName(cidNum, pairing.id) || getPairingName(cidNum, pairing.member1Id || pairing.external1Id);
|
||||
if (!displayName) return;
|
||||
const key = `pair:${classKey}:${displayName}`;
|
||||
if (existingKeys.has(key)) return;
|
||||
(byClass[classKey] ||= []).push({ position: nextPos++, displayName, classId: cidNum });
|
||||
existingKeys.add(key);
|
||||
});
|
||||
} else {
|
||||
unique.forEach(p => {
|
||||
const k = getStableKeyForParticipant(p);
|
||||
if (!k || existingKeys.has(k)) return;
|
||||
const memberLike = p.member ? p.member : {
|
||||
id: p.id,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName
|
||||
};
|
||||
(byClass[classKey] ||= []).push({ position: nextPos++, member: memberLike, classId: classKey === 'null' ? null : Number(classKey) });
|
||||
existingKeys.add(k);
|
||||
});
|
||||
}
|
||||
|
||||
byClass[classKey] = (byClass[classKey] || []).sort((a, b) => Number(a.position) - Number(b.position));
|
||||
});
|
||||
|
||||
Object.keys(byClass).forEach(k => {
|
||||
if (!byClass[k] || byClass[k].length === 0) delete byClass[k];
|
||||
});
|
||||
|
||||
return byClass;
|
||||
},
|
||||
groupPlacements() {
|
||||
// Extrahiere Gruppenplatzierungen
|
||||
const placements = [];
|
||||
|
||||
this.groups.forEach(group => {
|
||||
const rankings = this.groupRankings[group.groupId] || [];
|
||||
if (rankings.length > 0) {
|
||||
placements.push({
|
||||
groupId: group.groupId,
|
||||
groupNumber: group.groupNumber,
|
||||
classId: group.classId,
|
||||
rankings: rankings.map(r => ({
|
||||
id: r.id,
|
||||
position: r.position,
|
||||
name: r.name,
|
||||
seeded: r.seeded,
|
||||
points: r.points,
|
||||
setsWon: r.setsWon,
|
||||
setsLost: r.setsLost,
|
||||
setDiff: r.setDiff
|
||||
}))
|
||||
// Primär: aus groups + groupRankings
|
||||
if ((this.groups || []).length > 0) {
|
||||
this.groups.forEach(group => {
|
||||
const rankings = this.groupRankings[group.groupId] || [];
|
||||
if (rankings.length > 0) {
|
||||
placements.push({
|
||||
groupId: group.groupId,
|
||||
groupNumber: group.groupNumber,
|
||||
classId: group.classId,
|
||||
rankings: rankings.map(r => ({
|
||||
id: r.id,
|
||||
position: r.position,
|
||||
name: r.name,
|
||||
seeded: r.seeded,
|
||||
points: r.points,
|
||||
setsWon: r.setsWon,
|
||||
setsLost: r.setsLost,
|
||||
setDiff: r.setDiff,
|
||||
isExternal: r.isExternal || false
|
||||
}))
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (this.groupedRankingList && typeof this.groupedRankingList === 'object') {
|
||||
// Fallback: aus groupedRankingList (by classId -> groups)
|
||||
Object.entries(this.groupedRankingList).forEach(([classKey, groups]) => {
|
||||
(groups || []).forEach((g, idx) => {
|
||||
const rankings = Array.isArray(g.rankings) ? g.rankings : [];
|
||||
if (rankings.length === 0) return;
|
||||
placements.push({
|
||||
groupId: g.groupId || `${classKey}-${idx}`,
|
||||
groupNumber: g.groupNumber || (idx + 1),
|
||||
classId: g.classId != null ? g.classId : (classKey === 'null' ? null : Number(classKey)),
|
||||
rankings: rankings.map(r => ({
|
||||
id: r.id,
|
||||
position: r.position,
|
||||
name: r.name,
|
||||
seeded: r.seeded,
|
||||
points: r.points,
|
||||
setsWon: r.setsWon,
|
||||
setsLost: r.setsLost,
|
||||
setDiff: r.setDiff,
|
||||
isExternal: r.isExternal || false
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
return placements.sort((a, b) => {
|
||||
if (a.classId !== b.classId) {
|
||||
const aNum = a.classId || 999999;
|
||||
@@ -185,10 +480,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
shouldShowClass(classId) {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '__none__') {
|
||||
return true;
|
||||
}
|
||||
const selectedId = typeof this.selectedViewClass === 'string' ? parseInt(this.selectedViewClass) : this.selectedViewClass;
|
||||
const sel = this.selectedViewClass;
|
||||
if (sel === null || sel === undefined || sel === '' || this.isAllSelected) return true;
|
||||
if (sel === '__none__') return true; // 'Ohne Klasse' zeigt alle
|
||||
const selectedId = typeof sel === 'string' ? parseInt(sel) : sel;
|
||||
return classId === selectedId;
|
||||
},
|
||||
getClassName(classId) {
|
||||
@@ -203,132 +498,141 @@ export default {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
getMatchWinner(match) {
|
||||
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = match.tournamentResults || [];
|
||||
let win1 = 0, win2 = 0;
|
||||
results.forEach(r => {
|
||||
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
|
||||
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
|
||||
});
|
||||
|
||||
if (win1 > win2) {
|
||||
return this.getPlayerName(match.player1, match);
|
||||
} else if (win2 > win1) {
|
||||
return this.getPlayerName(match.player2, match);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getMatchLoser(match) {
|
||||
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = match.tournamentResults || [];
|
||||
let win1 = 0, win2 = 0;
|
||||
results.forEach(r => {
|
||||
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
|
||||
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
|
||||
});
|
||||
|
||||
if (win1 > win2) {
|
||||
const names = this.getMatchPlayerNames(match);
|
||||
return names ? names.name2 : null;
|
||||
} else if (win2 > win1) {
|
||||
const names = this.getMatchPlayerNames(match);
|
||||
return names ? names.name1 : null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPlayerName(player, match) {
|
||||
if (!player && !match) return this.$t('tournaments.unknown');
|
||||
|
||||
// Prüfe ob es ein Doppel ist
|
||||
if (match && match.classId) {
|
||||
const tournamentClass = this.tournamentClasses.find(c => c.id === match.classId);
|
||||
if (tournamentClass && tournamentClass.isDoubles) {
|
||||
// Finde die Paarung basierend auf player1Id oder player2Id
|
||||
const playerId = player?.id || (match.player1Id === player?.id ? match.player1Id : match.player2Id);
|
||||
const pairing = this.pairings.find(p =>
|
||||
p.classId === match.classId &&
|
||||
(p.member1Id === playerId || p.member2Id === playerId ||
|
||||
p.external1Id === playerId || p.external2Id === playerId)
|
||||
);
|
||||
|
||||
if (pairing) {
|
||||
const name1 = this.getPairingPlayerName(pairing, 1);
|
||||
const name2 = this.getPairingPlayerName(pairing, 2);
|
||||
return `${name1} / ${name2}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normale Spieler
|
||||
if (player) {
|
||||
if (player.member) {
|
||||
return `${player.member.firstName} ${player.member.lastName}`;
|
||||
} else if (player.firstName && player.lastName) {
|
||||
return `${player.firstName} ${player.lastName}`;
|
||||
}
|
||||
}
|
||||
return this.$t('tournaments.unknown');
|
||||
},
|
||||
getPairingPlayerName(pairing, playerNumber) {
|
||||
if (playerNumber === 1) {
|
||||
if (pairing.member1 && pairing.member1.member) {
|
||||
return `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}`;
|
||||
} else if (pairing.external1) {
|
||||
return `${pairing.external1.firstName} ${pairing.external1.lastName}`;
|
||||
}
|
||||
} else if (playerNumber === 2) {
|
||||
if (pairing.member2 && pairing.member2.member) {
|
||||
return `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}`;
|
||||
} else if (pairing.external2) {
|
||||
return `${pairing.external2.firstName} ${pairing.external2.lastName}`;
|
||||
}
|
||||
}
|
||||
return this.$t('tournaments.unknown');
|
||||
},
|
||||
getMatchPlayerNames(match) {
|
||||
const classId = match.classId;
|
||||
if (classId) {
|
||||
const tournamentClass = this.tournamentClasses.find(c => c.id === classId);
|
||||
if (tournamentClass && tournamentClass.isDoubles) {
|
||||
const pairing1 = this.pairings.find(p =>
|
||||
p.classId === classId &&
|
||||
(p.member1Id === match.player1Id || p.external1Id === match.player1Id ||
|
||||
p.member2Id === match.player1Id || p.external2Id === match.player1Id)
|
||||
);
|
||||
const pairing2 = this.pairings.find(p =>
|
||||
p.classId === classId &&
|
||||
(p.member1Id === match.player2Id || p.external1Id === match.player2Id ||
|
||||
p.member2Id === match.player2Id || p.external2Id === match.player2Id)
|
||||
);
|
||||
|
||||
if (pairing1 && pairing2) {
|
||||
const name1 = this.getPairingPlayerName(pairing1, 1) + ' / ' + this.getPairingPlayerName(pairing1, 2);
|
||||
const name2 = this.getPairingPlayerName(pairing2, 1) + ' / ' + this.getPairingPlayerName(pairing2, 2);
|
||||
return { name1, name2 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name1: this.getPlayerName(match.player1, match),
|
||||
name2: this.getPlayerName(match.player2, match)
|
||||
};
|
||||
},
|
||||
getEntryPlayerName(entry) {
|
||||
// Die entry hat die Struktur: { position, member, classId }
|
||||
// member ist ein Member-Objekt mit firstName/lastName direkt
|
||||
if (entry.member) {
|
||||
if (entry.member.firstName && entry.member.lastName) {
|
||||
return `${entry.member.firstName} ${entry.member.lastName}`;
|
||||
// Doppel: displayName vorhanden
|
||||
if (entry && entry.displayName) return entry.displayName;
|
||||
// Einzel: Member-Name
|
||||
const m = entry.member || {};
|
||||
const fn = (m.firstName || '').trim();
|
||||
const ln = (m.lastName || '').trim();
|
||||
if (fn || ln) return `${fn} ${ln}`.trim();
|
||||
return this.$t('tournaments.unknown');
|
||||
},
|
||||
isDoubles(classId) {
|
||||
const cid = classId === 'null' ? null : (typeof classId === 'string' ? parseInt(classId) : classId);
|
||||
if (cid == null) return false;
|
||||
const c = (this.tournamentClasses || []).find(x => x.id === cid);
|
||||
return Boolean(c && c.isDoubles);
|
||||
},
|
||||
openPlayerDialog(entry) {
|
||||
console.log('[openPlayerDialog] entry:', entry);
|
||||
// Für Doppel-Paarungen können wir keine Details anzeigen
|
||||
if (entry.displayName) {
|
||||
console.log('[openPlayerDialog] Doppel-Paarung, keine Details');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.clubId) {
|
||||
console.warn('[openPlayerDialog] clubId fehlt');
|
||||
return;
|
||||
}
|
||||
|
||||
const memberId = entry.member?.id;
|
||||
if (!memberId) {
|
||||
console.warn('[openPlayerDialog] Keine Member-ID:', {
|
||||
hasMember: !!entry.member,
|
||||
memberId: entry.member?.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe zuerst in externalParticipants, ob es ein externer Teilnehmer ist
|
||||
const externalParticipant = this.externalParticipants.find(p => p.id === memberId);
|
||||
if (externalParticipant) {
|
||||
// Externer Teilnehmer gefunden
|
||||
console.log('[openPlayerDialog] Externer Teilnehmer, ID:', memberId);
|
||||
this.selectedPlayerId = memberId;
|
||||
this.selectedPlayerIsExternal = true;
|
||||
this.selectedPlayerName = this.getEntryPlayerName(entry);
|
||||
this.showPlayerDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe in participants, ob es ein interner Teilnehmer ist
|
||||
const participant = this.participants.find(p => {
|
||||
// Prüfe verschiedene mögliche ID-Felder
|
||||
return (p.member && p.member.id === memberId) ||
|
||||
p.id === memberId ||
|
||||
p.clubMemberId === memberId;
|
||||
});
|
||||
|
||||
if (participant) {
|
||||
// Interner Teilnehmer gefunden
|
||||
const actualMemberId = participant.member?.id || participant.clubMemberId || memberId;
|
||||
console.log('[openPlayerDialog] Interner Teilnehmer, Member ID:', actualMemberId);
|
||||
this.selectedPlayerId = actualMemberId;
|
||||
this.selectedPlayerIsExternal = false;
|
||||
this.selectedPlayerName = this.getEntryPlayerName(entry);
|
||||
this.showPlayerDialog = true;
|
||||
} else {
|
||||
// Weder in participants noch in externalParticipants gefunden
|
||||
// Fallback: Versuche nochmal in externalParticipants mit firstName/lastName
|
||||
const entryName = this.getEntryPlayerName(entry);
|
||||
const nameParts = entryName.split(' ').filter(p => p);
|
||||
if (nameParts.length >= 2) {
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
const extByName = this.externalParticipants.find(p =>
|
||||
p.firstName === firstName && p.lastName === lastName
|
||||
);
|
||||
if (extByName) {
|
||||
console.log('[openPlayerDialog] Externer Teilnehmer (nach Name), ID:', extByName.id);
|
||||
this.selectedPlayerId = extByName.id;
|
||||
this.selectedPlayerIsExternal = true;
|
||||
this.selectedPlayerName = entryName;
|
||||
this.showPlayerDialog = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[openPlayerDialog] Teilnehmer nicht gefunden, versuche als intern:', {
|
||||
memberId,
|
||||
entryName
|
||||
});
|
||||
// Letzter Fallback: Annahme, dass es ein interner Member ist
|
||||
this.selectedPlayerId = memberId;
|
||||
this.selectedPlayerIsExternal = false;
|
||||
this.selectedPlayerName = this.getEntryPlayerName(entry);
|
||||
this.showPlayerDialog = true;
|
||||
}
|
||||
},
|
||||
openPlayerDialogFromRanking(ranking) {
|
||||
console.log('[openPlayerDialogFromRanking] ranking:', ranking);
|
||||
if (!this.clubId) {
|
||||
console.warn('[openPlayerDialogFromRanking] clubId nicht verfügbar');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe, ob es ein interner oder externer Teilnehmer ist
|
||||
// ranking.id ist die TournamentMember.id oder ExternalTournamentParticipant.id
|
||||
if (ranking.isExternal === true) {
|
||||
// Externer Teilnehmer: ranking.id ist die ExternalTournamentParticipant.id
|
||||
console.log('[openPlayerDialogFromRanking] Externer Teilnehmer, ID:', ranking.id);
|
||||
this.selectedPlayerId = ranking.id;
|
||||
this.selectedPlayerIsExternal = true;
|
||||
this.selectedPlayerName = ranking.name;
|
||||
this.showPlayerDialog = true;
|
||||
} else {
|
||||
// Interner Teilnehmer: Finde den Participant, um die clubMemberId zu bekommen
|
||||
const participant = this.participants.find(p => p.id === ranking.id);
|
||||
console.log('[openPlayerDialogFromRanking] Interner Teilnehmer, participant:', participant);
|
||||
if (participant && participant.member && participant.member.id) {
|
||||
console.log('[openPlayerDialogFromRanking] Öffne Dialog für Member ID:', participant.member.id);
|
||||
this.selectedPlayerId = participant.member.id;
|
||||
this.selectedPlayerIsExternal = false;
|
||||
this.selectedPlayerName = ranking.name;
|
||||
this.showPlayerDialog = true;
|
||||
} else if (participant && participant.clubMemberId) {
|
||||
// Fallback: Verwende clubMemberId direkt
|
||||
console.log('[openPlayerDialogFromRanking] Öffne Dialog für clubMemberId:', participant.clubMemberId);
|
||||
this.selectedPlayerId = participant.clubMemberId;
|
||||
this.selectedPlayerIsExternal = false;
|
||||
this.selectedPlayerName = ranking.name;
|
||||
this.showPlayerDialog = true;
|
||||
} else {
|
||||
console.warn('[openPlayerDialogFromRanking] Teilnehmer nicht gefunden für ranking.id:', ranking.id);
|
||||
}
|
||||
}
|
||||
return this.$t('tournaments.unknown');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -350,6 +654,25 @@ export default {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.class-type-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.class-type-badge.singles {
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.class-type-badge.doubles {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.group-table {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -380,6 +703,11 @@ th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Platz-Spalte kompakt */
|
||||
.col-place {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
.seeded-star {
|
||||
color: #ff9800;
|
||||
margin-right: 0.25rem;
|
||||
@@ -390,5 +718,21 @@ th {
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
.player-name-clickable {
|
||||
cursor: pointer;
|
||||
color: #1976d2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.player-name-clickable:hover {
|
||||
color: #1565c0;
|
||||
}
|
||||
</style>
|
||||
/* Spaltenbreite für Platz: 4em */
|
||||
table thead th:first-child,
|
||||
table tbody td:first-child {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section v-if="groupMatches.length" class="group-matches">
|
||||
<section v-if="filteredGroupMatches.length" class="group-matches">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -21,7 +21,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
|
||||
<tr v-for="m in filteredGroupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
|
||||
<td>{{ m.groupRound }}</td>
|
||||
<td>
|
||||
<template v-if="getGroupClassName(m.groupId)">
|
||||
@@ -45,7 +45,10 @@
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="!m.isFinished">
|
||||
<template v-if="m.result === 'BYE'">
|
||||
BYE
|
||||
</template>
|
||||
<template v-else-if="!m.isFinished">
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
<input
|
||||
@@ -92,22 +95,22 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div v-if="participants.length > 1 && !groupMatches.length && !knockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
|
||||
<div v-if="participants.length > 1 && !filteredGroupMatches.length && !filteredKnockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
|
||||
<button @click="$emit('start-matches')">
|
||||
{{ $t('tournaments.createMatches') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="canStartKnockout && !showKnockout && getTotalNumberOfGroups > 1" class="ko-start">
|
||||
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1" class="ko-start">
|
||||
<button @click="$emit('start-knockout')">
|
||||
{{ $t('tournaments.startKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showKnockout && canResetKnockout && getTotalNumberOfGroups > 1" class="ko-reset" style="margin-top:1rem">
|
||||
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1" class="ko-reset" style="margin-top:1rem">
|
||||
<button @click="$emit('reset-knockout')" class="trash-btn">
|
||||
🗑️ {{ $t('tournaments.deleteKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="showKnockout && getTotalNumberOfGroups > 1" class="ko-round">
|
||||
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -121,7 +124,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in knockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
|
||||
<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>
|
||||
@@ -185,9 +188,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section v-if="Object.keys(groupedRankingList).length > 0" class="ranking">
|
||||
<section v-if="Object.keys(filteredGroupedRankingList).length > 0" class="ranking">
|
||||
<h4>Rangliste</h4>
|
||||
<template v-for="(classKey, idx) in Object.keys(groupedRankingList).sort((a, b) => {
|
||||
<template v-for="(classKey, idx) in Object.keys(filteredGroupedRankingList).sort((a, b) => {
|
||||
const aNum = a === 'null' ? 999999 : parseInt(a);
|
||||
const bNum = b === 'null' ? 999999 : parseInt(b);
|
||||
return aNum - bNum;
|
||||
@@ -202,7 +205,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey]" :key="`${entry.member.id}-${entryIdx}`">
|
||||
<tr v-for="(entry, entryIdx) in filteredGroupedRankingList[classKey].filter(e => Number(e.position) <= 3)" :key="`${entry.member.id}-${entryIdx}`">
|
||||
<td>{{ entry.position }}.</td>
|
||||
<td>
|
||||
{{ entry.member.firstName }}
|
||||
@@ -286,6 +289,71 @@ export default {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredGroupMatches() {
|
||||
return this.filterMatchesByClass(this.groupMatches);
|
||||
},
|
||||
filteredKnockoutMatches() {
|
||||
return this.filterMatchesByClass(this.knockoutMatches);
|
||||
},
|
||||
filteredGroupedRankingList() {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return this.groupedRankingList;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
const result = {};
|
||||
if (this.groupedRankingList['null']) {
|
||||
result['null'] = this.groupedRankingList['null'];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Filtere nach der ausgewählten Klasse
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
if (Number.isNaN(selectedId)) {
|
||||
return this.groupedRankingList;
|
||||
}
|
||||
const result = {};
|
||||
const classKey = String(selectedId);
|
||||
if (this.groupedRankingList[classKey]) {
|
||||
result[classKey] = this.groupedRankingList[classKey];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
numberOfGroupsForSelectedClass() {
|
||||
// Zähle direkt die Gruppen für die ausgewählte Klasse
|
||||
// Nur Stage 1 Gruppen (stageId null/undefined) zählen
|
||||
// Und nur Gruppen mit mindestens einem Teilnehmer
|
||||
let groupsToCount = this.groups.filter(g =>
|
||||
(!g.stageId || g.stageId === null || g.stageId === undefined) &&
|
||||
g.participants && Array.isArray(g.participants) && g.participants.length > 0
|
||||
);
|
||||
|
||||
// Wenn keine Klasse ausgewählt ist, zähle alle Stage 1 Gruppen
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return groupsToCount.length;
|
||||
}
|
||||
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
return groupsToCount.filter(g => g.classId === null || g.classId === undefined).length;
|
||||
}
|
||||
|
||||
// Filtere nach der ausgewählten Klasse
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
if (Number.isNaN(selectedId)) {
|
||||
return groupsToCount.length;
|
||||
}
|
||||
|
||||
return groupsToCount.filter(g => {
|
||||
if (g.classId === null || g.classId === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Number(g.classId) === selectedId;
|
||||
}).length;
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:selectedViewClass',
|
||||
'update:activeMatchId',
|
||||
@@ -302,6 +370,28 @@ export default {
|
||||
'reset-knockout'
|
||||
],
|
||||
methods: {
|
||||
filterMatchesByClass(matches) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return matches;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
return matches.filter(m => m.classId === null || m.classId === undefined);
|
||||
}
|
||||
// Filtere nach der ausgewählten Klasse
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
if (Number.isNaN(selectedId)) {
|
||||
return matches;
|
||||
}
|
||||
return matches.filter(m => {
|
||||
const matchClassId = m.classId;
|
||||
if (matchClassId === null || matchClassId === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Number(matchClassId) === selectedId;
|
||||
});
|
||||
},
|
||||
getGroupClassName(groupId) {
|
||||
if (!groupId) return '';
|
||||
const group = this.groups.find(g => g.groupId === groupId);
|
||||
@@ -356,6 +446,7 @@ export default {
|
||||
};
|
||||
},
|
||||
getPlayerName(p) {
|
||||
if (!p) return 'Freilos';
|
||||
if (p.member) {
|
||||
return p.member.firstName + ' ' + p.member.lastName;
|
||||
} else {
|
||||
@@ -395,8 +486,12 @@ export default {
|
||||
return `${win1}:${win2}`;
|
||||
},
|
||||
winnerIsPlayer1(match) {
|
||||
const [w1, w2] = this.getSetsString(match).split(':').map(Number);
|
||||
return w1 > w2;
|
||||
if (match.result === 'BYE') {
|
||||
// Gewinner ist der vorhandene Spieler
|
||||
return !!match.player1 && !match.player2;
|
||||
}
|
||||
const [t, n] = this.getSetsString(match).split(':').map(Number);
|
||||
return t > n;
|
||||
},
|
||||
isEditing(match, set) {
|
||||
return (
|
||||
|
||||
@@ -649,6 +649,9 @@
|
||||
"createMatches": "Spiele erstellen",
|
||||
"startKORound": "K.o.-Runde starten",
|
||||
"deleteKORound": "K.o.-Runde",
|
||||
"address": "Adresse",
|
||||
"showPlayerDetails": "Spielerdetails anzeigen",
|
||||
"noPlayerDataAvailable": "Keine Spielerdaten verfügbar",
|
||||
"koRound": "K.-o.-Runde",
|
||||
"errorUpdatingTournament": "Fehler beim Aktualisieren des Turniers.",
|
||||
"pleaseEnterDate": "Bitte geben Sie ein Datum ein!",
|
||||
|
||||
@@ -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="createMatches()"
|
||||
@highlight-match="highlightMatch"
|
||||
/>
|
||||
|
||||
@@ -222,6 +225,7 @@
|
||||
:participants="participants"
|
||||
:external-participants="externalParticipants"
|
||||
:pairings="pairings"
|
||||
:club-id="currentClub"
|
||||
@update:selectedViewClass="selectedViewClass = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -371,6 +375,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/);
|
||||
@@ -483,9 +488,12 @@ export default {
|
||||
setsWon: p.setsWon || 0,
|
||||
setsLost: p.setsLost || 0,
|
||||
setDiff: p.setDiff || 0,
|
||||
pointsWon: p.pointsWon || 0,
|
||||
pointsLost: p.pointsLost || 0,
|
||||
pointRatio: p.pointRatio || 0
|
||||
pointsWon: Math.abs(p.pointsWon || 0),
|
||||
pointsLost: Math.abs(p.pointsLost || 0),
|
||||
pointRatio: p.pointRatio || 0,
|
||||
matchesWon: p.matchesWon || 0,
|
||||
matchesLost: p.matchesLost || 0,
|
||||
isExternal: p.isExternal || false
|
||||
}));
|
||||
});
|
||||
return rankings;
|
||||
@@ -632,8 +640,17 @@ export default {
|
||||
// Finde alle Spieler, die noch im Turnier sind (Gewinner von abgeschlossenen Matches, die noch nicht ausgeschieden sind)
|
||||
const stillInTournament = new Set();
|
||||
finishedMatches.forEach(match => {
|
||||
const [a, b] = match.result.split(':').map(n => +n);
|
||||
const winner = a > b ? match.player1 : match.player2;
|
||||
// BYE oder regulär robust auswerten
|
||||
let winner = null;
|
||||
if (match.result === 'BYE') {
|
||||
winner = match.player1 || match.player2 || null;
|
||||
} else if (typeof match.result === 'string' && match.result.includes(':')) {
|
||||
const [sa, sb] = match.result.split(':').map(n => Number(n));
|
||||
if (Number.isFinite(sa) && Number.isFinite(sb)) {
|
||||
winner = sa > sb ? match.player1 : match.player2;
|
||||
}
|
||||
}
|
||||
if (!winner) return;
|
||||
const winnerId = winner.member ? winner.member.id : winner.id;
|
||||
|
||||
// Prüfe, ob der Gewinner noch in einem nicht abgeschlossenen Match ist
|
||||
@@ -641,9 +658,9 @@ export default {
|
||||
!m.isFinished &&
|
||||
m.classId === match.classId && // WICHTIG: Nur Matches derselben Klasse prüfen
|
||||
((m.player1 && m.player1.member && m.player1.member.id === winnerId) ||
|
||||
(m.player1 && m.player1.id === winnerId) ||
|
||||
(m.player2 && m.player2.member && m.player2.member.id === winnerId) ||
|
||||
(m.player2 && m.player2.id === winnerId))
|
||||
(m.player1 && m.player1.id === winnerId) ||
|
||||
(m.player2 && m.player2.member && m.player2.member.id === winnerId) ||
|
||||
(m.player2 && m.player2.id === winnerId))
|
||||
);
|
||||
|
||||
if (hasUnfinishedMatch) {
|
||||
@@ -663,6 +680,7 @@ export default {
|
||||
|
||||
// Verarbeite jede Klasse separat
|
||||
Object.entries(matchesByClass).forEach(([classKey, classMatches]) => {
|
||||
const hasThirdPlace = classMatches.some(m => (m.round || '').toLowerCase().includes('platz 3'));
|
||||
// Gruppiere nach Runden innerhalb dieser Klasse
|
||||
const roundsMap = {};
|
||||
classMatches.forEach(m => {
|
||||
@@ -682,44 +700,136 @@ export default {
|
||||
return b[1].length - a[1].length;
|
||||
});
|
||||
|
||||
// Bestimme Positionen basierend auf abgeschlossenen Runden (pro Klasse)
|
||||
let currentPosition = 1;
|
||||
// Hilfsfunktion: Positionszahl für KO-Runden nach Anzahl Matches
|
||||
const positionForRound = (roundName, matchesCount) => {
|
||||
const rn = (roundName || '').toLowerCase();
|
||||
if (rn === 'finale') return 1; // wird separat behandelt
|
||||
if (rn.includes('halbfinale')) return 3; // nur wenn KEIN Platz-3-Spiel existiert
|
||||
// Viertelfinale: 4 Matches -> Plätze 5..8
|
||||
if (rn.includes('viertelfinale')) return 5;
|
||||
// Achtelfinale: 8 Matches -> Plätze 9..16
|
||||
if (rn.includes('achtelfinale')) return 9;
|
||||
// Generisch: matchesCount + 1
|
||||
return Number(matchesCount) + 1;
|
||||
};
|
||||
|
||||
sortedRounds.forEach(([roundName, matches]) => {
|
||||
if (roundName === 'finale') {
|
||||
// Finale: 1. und 2. Platz
|
||||
const match = matches[0];
|
||||
const [s1, s2] = match.result.split(':').map(n => +n);
|
||||
const winner = s1 > s2 ? match.player1 : match.player2;
|
||||
const loser = s1 > s2 ? match.player2 : match.player1;
|
||||
let winner = null;
|
||||
let loser = null;
|
||||
if (match.result === 'BYE') {
|
||||
winner = match.player1 || match.player2 || null;
|
||||
loser = winner === match.player1 ? match.player2 : match.player1;
|
||||
} else if (typeof match.result === 'string' && match.result.includes(':')) {
|
||||
const [s1, s2] = match.result.split(':').map(n => Number(n));
|
||||
if (Number.isFinite(s1) && Number.isFinite(s2)) {
|
||||
winner = s1 > s2 ? match.player1 : match.player2;
|
||||
loser = s1 > s2 ? match.player2 : match.player1;
|
||||
}
|
||||
}
|
||||
if (!winner || !loser) return;
|
||||
const winnerId = winner.member ? winner.member.id : winner.id;
|
||||
const loserId = loser.member ? loser.member.id : loser.id;
|
||||
|
||||
// Nur hinzufügen, wenn nicht mehr im Turnier
|
||||
const toMember = (p) => p?.member ? p.member : (p ? { id: p.id, firstName: p.firstName, lastName: p.lastName } : null);
|
||||
if (!stillInTournament.has(`${classKey}-${winnerId}`)) {
|
||||
list.push({ position: 1, member: winner.member, classId: match.classId });
|
||||
const mLike = toMember(winner);
|
||||
if (mLike) list.push({ position: 1, member: mLike, classId: match.classId });
|
||||
}
|
||||
if (!stillInTournament.has(`${classKey}-${loserId}`)) {
|
||||
list.push({ position: 2, member: loser.member, classId: match.classId });
|
||||
const mLike = toMember(loser);
|
||||
if (mLike) list.push({ position: 2, member: mLike, classId: match.classId });
|
||||
}
|
||||
currentPosition = 3;
|
||||
// Finale setzt 1/2, keine globale Positionsvariable nötig
|
||||
} else {
|
||||
// Überspringe Platz-3-Runde hier; sie wird separat als 3/4 bewertet
|
||||
if ((roundName || '').toLowerCase().includes('platz 3')) {
|
||||
return;
|
||||
}
|
||||
// Andere Runden: Alle Verlierer bekommen die gleiche Position
|
||||
const numMatches = matches.length;
|
||||
const position = currentPosition;
|
||||
let position = positionForRound(roundName, numMatches);
|
||||
matches.forEach(match => {
|
||||
const [a, b] = match.result.split(':').map(n => +n);
|
||||
const knockedOut = a > b ? match.player2 : match.player1;
|
||||
let knockedOut = null;
|
||||
if (match.result === 'BYE') {
|
||||
// Der fehlende Spieler gilt als ausgeschieden
|
||||
knockedOut = (!match.player1) ? match.player1 : (!match.player2 ? match.player2 : null);
|
||||
// Falls beide vorhanden (sollte nicht BYE sein), fall back
|
||||
if (!knockedOut) {
|
||||
const [a, b] = (match.result || '').split(':').map(n => Number(n));
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) {
|
||||
knockedOut = a > b ? match.player2 : match.player1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [a, b] = (match.result || '').split(':').map(n => Number(n));
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) {
|
||||
knockedOut = a > b ? match.player2 : match.player1;
|
||||
}
|
||||
}
|
||||
if (!knockedOut) return;
|
||||
const knockedOutId = knockedOut.member ? knockedOut.member.id : knockedOut.id;
|
||||
|
||||
// Wenn ein Platz-3-Spiel existiert, füge Halbfinal-Verlierer hier NICHT als Platz 3 hinzu
|
||||
const isSemi = (match.round || '').toLowerCase().includes('halbfinale');
|
||||
if (hasThirdPlace && isSemi) return;
|
||||
|
||||
// Nur hinzufügen, wenn nicht mehr im Turnier
|
||||
if (!stillInTournament.has(`${classKey}-${knockedOutId}`)) {
|
||||
list.push({ position: position, member: knockedOut.member, classId: match.classId });
|
||||
const mLike = knockedOut.member ? knockedOut.member : { id: knockedOut.id, firstName: knockedOut.firstName, lastName: knockedOut.lastName };
|
||||
if (mLike) list.push({ position: position, member: mLike, classId: match.classId });
|
||||
}
|
||||
});
|
||||
currentPosition += numMatches;
|
||||
// Bei Viertelfinale/Achtelfinale erhalten Verlierer die korrekten Startpositionen (5 bzw. 9)
|
||||
}
|
||||
});
|
||||
|
||||
// Platz-3-Spiel explizit werten (3/4)
|
||||
const thirdMatch = classMatches.find(m => (m.round || '').toLowerCase().includes('platz 3'));
|
||||
if (thirdMatch && thirdMatch.isFinished) {
|
||||
let winner = null;
|
||||
let loser = null;
|
||||
if (thirdMatch.result === 'BYE') {
|
||||
winner = thirdMatch.player1 || thirdMatch.player2 || null;
|
||||
loser = winner === thirdMatch.player1 ? thirdMatch.player2 : thirdMatch.player1;
|
||||
} else if (typeof thirdMatch.result === 'string' && thirdMatch.result.includes(':')) {
|
||||
const [s1, s2] = thirdMatch.result.split(':').map(n => Number(n));
|
||||
if (Number.isFinite(s1) && Number.isFinite(s2)) {
|
||||
winner = s1 > s2 ? thirdMatch.player1 : thirdMatch.player2;
|
||||
loser = s1 > s2 ? thirdMatch.player2 : thirdMatch.player1;
|
||||
}
|
||||
}
|
||||
const toMember = (p) => p?.member ? p.member : (p ? { id: p.id, firstName: p.firstName, lastName: p.lastName } : null);
|
||||
if (winner) {
|
||||
const mLike = toMember(winner);
|
||||
if (mLike) list.push({ position: 3, member: mLike, classId: thirdMatch.classId });
|
||||
}
|
||||
if (loser) {
|
||||
const mLike = toMember(loser);
|
||||
if (mLike) list.push({ position: 4, member: mLike, classId: thirdMatch.classId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Dedupliziere Einträge pro Klasse/Spieler und behalte die beste (niedrigste) Position
|
||||
const dedup = new Map();
|
||||
const out = [];
|
||||
for (const entry of list) {
|
||||
const key = `${entry.classId || 'null'}:${entry.member?.id ?? (entry.member?.firstName || '') + '|' + (entry.member?.lastName || '')}`;
|
||||
const existing = dedup.get(key);
|
||||
if (!existing || Number(entry.position) < Number(existing.position)) {
|
||||
dedup.set(key, entry);
|
||||
}
|
||||
}
|
||||
for (const v of dedup.values()) out.push(v);
|
||||
return out.sort((a, b) => {
|
||||
const ac = a.classId || 999999;
|
||||
const bc = b.classId || 999999;
|
||||
if (ac !== bc) return ac - bc;
|
||||
return Number(a.position) - Number(b.position);
|
||||
});
|
||||
|
||||
return list.sort((a, b) => {
|
||||
@@ -766,9 +876,8 @@ export default {
|
||||
},
|
||||
|
||||
canResetKnockout() {
|
||||
// KO‑Matches existieren und keiner ist beendet
|
||||
return this.knockoutMatches.length > 0
|
||||
&& this.knockoutMatches.every(m => !m.isFinished);
|
||||
// Zeige den Löschen‑Button, sobald KO‑Matches existieren
|
||||
return this.knockoutMatches.length > 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -862,6 +971,10 @@ export default {
|
||||
disconnectSocket();
|
||||
},
|
||||
methods: {
|
||||
// Fallback-Kompatibilität: einige Aufrufe erwarten loadParticipants()
|
||||
async loadParticipants() {
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
setActiveTab(tab) {
|
||||
this.activeTab = tab;
|
||||
},
|
||||
@@ -1045,7 +1158,11 @@ export default {
|
||||
this.currentTournamentDate = tournament.date || '';
|
||||
this.currentWinningSets = tournament.winningSets || 3;
|
||||
this.isGroupTournament = tournament.type === 'groups';
|
||||
this.numberOfGroups = tournament.numberOfGroups;
|
||||
// Defensive: Backend/DB kann (historisch/UI-default) 0/null liefern.
|
||||
// Für gruppenbasierte Turniere ohne Klassen brauchen wir hier aber eine sinnvolle Zahl,
|
||||
// sonst sendet die UI später wieder `0` an `/tournament/groups`.
|
||||
const loadedGroups = Number(tournament.numberOfGroups);
|
||||
this.numberOfGroups = Number.isFinite(loadedGroups) && loadedGroups > 0 ? loadedGroups : 1;
|
||||
this.advancingPerGroup = tournament.advancingPerGroup;
|
||||
|
||||
// Prüfe, ob es einen Trainingstag für das Turnierdatum gibt
|
||||
@@ -1098,17 +1215,20 @@ export default {
|
||||
}
|
||||
});
|
||||
// Setze Gruppen neu, um Vue-Reaktivität sicherzustellen
|
||||
this.groups = [...gRes.data];
|
||||
const groupsData = Array.isArray(gRes.data)
|
||||
? gRes.data
|
||||
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
|
||||
this.groups = [...groupsData];
|
||||
|
||||
// Erstelle Mapping von groupId zu groupNumber
|
||||
const groupIdToNumberMap = this.groups.reduce((m, g) => {
|
||||
const groupIdToNumberMap = (this.groups || []).reduce((m, g) => {
|
||||
m[g.groupId] = g.groupNumber;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
// Stelle sicher, dass seeded-Feld vorhanden ist (für alte Einträge)
|
||||
// Initialisiere auch groupNumber basierend auf groupId
|
||||
this.participants = pRes.data.map(p => ({
|
||||
this.participants = (Array.isArray(pRes.data) ? pRes.data : []).map(p => ({
|
||||
...p,
|
||||
seeded: p.seeded || false,
|
||||
groupNumber: p.groupId ? (groupIdToNumberMap[p.groupId] || null) : null
|
||||
@@ -1150,7 +1270,7 @@ export default {
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der externen Teilnehmer ohne Klasse:', error);
|
||||
}
|
||||
this.externalParticipants = allExternalParticipants.map(p => ({
|
||||
this.externalParticipants = (Array.isArray(allExternalParticipants) ? allExternalParticipants : []).map(p => ({
|
||||
...p,
|
||||
seeded: p.seeded || false,
|
||||
isExternal: true,
|
||||
@@ -1198,6 +1318,41 @@ export default {
|
||||
|
||||
this.showKnockout = this.matches.some(m => m.round !== 'group');
|
||||
},
|
||||
|
||||
async loadMatches() {
|
||||
// Lade nur die Matches, ohne die Teilnehmer-Daten zu überschreiben
|
||||
const mRes = await apiClient.get(
|
||||
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
|
||||
);
|
||||
const grpMap = this.groups.reduce((m, g) => {
|
||||
m[g.groupId] = g.groupNumber;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
this.matches = mRes.data.map(m => {
|
||||
// Verwende groupId aus dem Backend, falls vorhanden, sonst aus den Spielern
|
||||
const matchGroupId = m.groupId || m.player1?.groupId || m.player2?.groupId;
|
||||
|
||||
// Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen)
|
||||
const groupRound = m.groupRound || m.group_round || 0;
|
||||
|
||||
const groupNumber = grpMap[matchGroupId] || 0;
|
||||
|
||||
return {
|
||||
...m,
|
||||
groupId: matchGroupId,
|
||||
groupNumber: groupNumber,
|
||||
groupRound: groupRound,
|
||||
resultInput: '',
|
||||
isActive: m.isActive || false
|
||||
};
|
||||
});
|
||||
|
||||
// Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden
|
||||
this.showParticipants = this.matches.length === 0;
|
||||
|
||||
this.showKnockout = this.matches.some(m => m.round !== 'group');
|
||||
},
|
||||
|
||||
async handleTournamentChanged(data) {
|
||||
if (!data || !data.tournamentId) {
|
||||
@@ -1429,6 +1584,9 @@ export default {
|
||||
}, {});
|
||||
const r = await apiClient.post('/tournament/participant', {
|
||||
clubId: this.currentClub,
|
||||
// Wenn ohne Klasse hinzugefügt wird, braucht das Backend die Turnier-ID.
|
||||
// (Bei klassengebundenen Teilnehmern ist sie optional, schadet aber nicht.)
|
||||
tournamentId: this.selectedDate,
|
||||
classId: classId,
|
||||
participant: this.selectedMember
|
||||
});
|
||||
@@ -1464,8 +1622,10 @@ export default {
|
||||
|
||||
async createGroups() {
|
||||
try {
|
||||
// Wenn Klassen vorhanden sind, verwende groupsPerClass
|
||||
if (this.tournamentClasses.length > 0) {
|
||||
// Wenn Klassen vorhanden sind, verwende groupsPerClass.
|
||||
// Achtung: Auch für "Ohne Klasse" (selectedViewClass='__none__') ist das der richtige Pfad,
|
||||
// sonst fällt die UI in den alten Fallback und sendet `numberOfGroups` (häufig 1).
|
||||
if (this.tournamentClasses.length > 0 || this.selectedViewClass === '__none__') {
|
||||
await apiClient.post('/tournament/groups/create', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
@@ -1473,10 +1633,11 @@ export default {
|
||||
});
|
||||
} else {
|
||||
// Fallback: Verwende numberOfGroups wie bisher
|
||||
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
|
||||
await apiClient.put('/tournament/groups', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
numberOfGroups: this.numberOfGroups
|
||||
numberOfGroups: desired
|
||||
});
|
||||
}
|
||||
await this.loadTournamentData();
|
||||
@@ -1636,7 +1797,76 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async startKnockout() {
|
||||
async startKnockout() {
|
||||
// Wenn eine Stage-Konfiguration existiert, ist /tournament/stages/advance der
|
||||
// korrekte Weg, weil nur dort die Pool-Regeln (z.B. Plätze 1,2) berücksichtigt werden.
|
||||
// Fallback ist die Legacy-Route /tournament/knockout.
|
||||
try {
|
||||
const stagesRes = await apiClient.get('/tournament/stages', {
|
||||
params: {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
}
|
||||
});
|
||||
|
||||
const stages = stagesRes?.data?.stages;
|
||||
if (Array.isArray(stages) && stages.length > 0) {
|
||||
// Backend arbeitet mit expliziten Stage-Indizes (z.B. 1 und 3), die nicht
|
||||
// zwingend 1..N sind. Daher müssen wir die Indizes aus der Antwort ableiten.
|
||||
const normalizedStages = stages
|
||||
.map(s => ({
|
||||
...s,
|
||||
// Prefer explicit index field; fall back to id for ordering if needed
|
||||
stageIndex: Number(s.stageIndex ?? s.index ?? s.id),
|
||||
stageId: Number(s.id ?? s.stageId ?? s.stageIndex)
|
||||
}))
|
||||
.filter(s => Number.isFinite(s.stageIndex));
|
||||
|
||||
// Ermittle die Reihenfolge der Stages
|
||||
const ordered = normalizedStages.sort((a, b) => a.stageIndex - b.stageIndex);
|
||||
const groupStage = ordered.find(s => (s.type || s.targetType || s.target) === 'groups');
|
||||
const knockoutStage = ordered.find(s => (s.type || s.targetType || s.target) === 'knockout');
|
||||
|
||||
if (groupStage && knockoutStage) {
|
||||
// Falls es Zwischenstufen vom Typ 'groups' gibt, iteriere bis zur KO‑Stufe
|
||||
let fromIdx = groupStage.stageIndex;
|
||||
let fromId = groupStage.stageId;
|
||||
for (const stage of ordered) {
|
||||
if (stage.stageIndex <= fromIdx) continue;
|
||||
// Advance Schrittweise zur nächsten Stage; prefer IDs if backend expects them
|
||||
const payload = {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
};
|
||||
if (Number.isFinite(fromId) && Number.isFinite(stage.stageId)) {
|
||||
payload.fromStageId = fromId;
|
||||
payload.toStageId = stage.stageId;
|
||||
} else {
|
||||
payload.fromStageIndex = fromIdx;
|
||||
payload.toStageIndex = stage.stageIndex;
|
||||
}
|
||||
await apiClient.post('/tournament/stages/advance', payload);
|
||||
|
||||
// Update trackers
|
||||
fromIdx = stage.stageIndex;
|
||||
fromId = stage.stageId;
|
||||
|
||||
// Wenn KO erreicht, beende
|
||||
if ((stage.type || stage.targetType || stage.target) === 'knockout') {
|
||||
await this.loadTournamentData();
|
||||
return;
|
||||
}
|
||||
|
||||
// Nach jedem Schritt neu laden, damit Folgeschritt korrekte Daten hat
|
||||
await this.loadTournamentData();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorieren und Legacy-Fallback nutzen.
|
||||
// (z.B. wenn Endpoint nicht verfügbar oder Stages nicht konfiguriert)
|
||||
}
|
||||
|
||||
await apiClient.post('/tournament/knockout', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
@@ -1664,13 +1894,33 @@ export default {
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
|
||||
async createMatches() {
|
||||
if (!this.isGroupTournament) {
|
||||
return;
|
||||
}
|
||||
if (!this.groups.length) {
|
||||
await this.createGroups();
|
||||
}
|
||||
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
|
||||
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
|
||||
? this.selectedViewClass
|
||||
: null;
|
||||
await apiClient.post('/tournament/matches/create', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
classId: classId
|
||||
});
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
|
||||
async onModusChange() {
|
||||
const type = this.isGroupTournament ? 'groups' : 'knockout';
|
||||
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
|
||||
await apiClient.post('/tournament/modus', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
type,
|
||||
numberOfGroups: this.numberOfGroups,
|
||||
numberOfGroups: desired,
|
||||
advancingPerGroup: this.advancingPerGroup
|
||||
});
|
||||
await this.loadTournamentData();
|
||||
@@ -1685,9 +1935,14 @@ export default {
|
||||
},
|
||||
|
||||
async resetMatches() {
|
||||
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
|
||||
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
|
||||
? this.selectedViewClass
|
||||
: null;
|
||||
await apiClient.post('/tournament/matches/reset', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
tournamentId: this.selectedDate,
|
||||
classId: classId
|
||||
});
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
@@ -1725,7 +1980,8 @@ export default {
|
||||
|
||||
async onGroupCountChange() {
|
||||
// Wenn Klassen vorhanden sind, speichere groupsPerClass, sonst numberOfGroups
|
||||
if (this.tournamentClasses.length > 0) {
|
||||
// Hinweis: Bei "Ohne Klasse" wird groupsPerClass['null'] genutzt, auch wenn Klassen existieren.
|
||||
if (this.tournamentClasses.length > 0 || this.selectedViewClass === '__none__') {
|
||||
// Speichere groupsPerClass für die aktuelle Klasse
|
||||
// Die Werte sind bereits in this.groupsPerClass gespeichert durch den setter von groupsPerClassInput
|
||||
// Wir müssen nichts speichern, da groupsPerClass nur lokal verwendet wird
|
||||
@@ -1733,11 +1989,12 @@ export default {
|
||||
return;
|
||||
} else {
|
||||
// Fallback: Verwende numberOfGroups wie bisher
|
||||
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
|
||||
await apiClient.post('/tournament/modus', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
type: this.isGroupTournament ? 'groups' : 'knockout',
|
||||
numberOfGroups: this.numberOfGroups
|
||||
numberOfGroups: desired
|
||||
});
|
||||
await this.loadTournamentData();
|
||||
}
|
||||
@@ -1845,12 +2102,14 @@ export default {
|
||||
|
||||
async resetKnockout() {
|
||||
try {
|
||||
await apiClient.delete('/tournament/matches/knockout', {
|
||||
data: {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
}
|
||||
});
|
||||
const payload = {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
};
|
||||
if (this.selectedViewClass != null && this.selectedViewClass !== '__none__') {
|
||||
payload.classId = Number(this.selectedViewClass);
|
||||
}
|
||||
await apiClient.delete('/tournament/matches/knockout', { data: payload });
|
||||
await this.loadTournamentData();
|
||||
} catch (err) {
|
||||
const message = safeErrorMessage(err, this.$t('tournaments.errorResettingKORound'));
|
||||
@@ -2005,8 +2264,17 @@ export default {
|
||||
|
||||
console.log('[updateParticipantGroup] Updating participant:', participant.id, 'to groupNumber:', groupNumber, 'isExternal:', participant.isExternal);
|
||||
|
||||
// Aktualisiere lokal
|
||||
participant.groupNumber = groupNumber;
|
||||
// Speichere die alte groupNumber für den Fall eines Fehlers
|
||||
const oldGroupNumber = participant.groupNumber;
|
||||
|
||||
// Aktualisiere lokal sofort für responsive UI (mit Vue-Reaktivität)
|
||||
if (this.$set) {
|
||||
// Vue 2
|
||||
this.$set(participant, 'groupNumber', groupNumber);
|
||||
} else {
|
||||
// Vue 3
|
||||
participant.groupNumber = groupNumber;
|
||||
}
|
||||
|
||||
// Sende nur diesen einen Teilnehmer an Backend
|
||||
try {
|
||||
@@ -2025,17 +2293,86 @@ export default {
|
||||
this.groups = [...response.data];
|
||||
console.log('[updateParticipantGroup] Updated groups:', this.groups);
|
||||
} else {
|
||||
// Fallback: Lade Daten neu
|
||||
await this.loadTournamentData();
|
||||
// Fallback: Lade Gruppen neu
|
||||
const gRes = await apiClient.get('/tournament/groups', {
|
||||
params: {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
}
|
||||
});
|
||||
const groupsData = Array.isArray(gRes.data)
|
||||
? gRes.data
|
||||
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
|
||||
this.groups = [...groupsData];
|
||||
}
|
||||
// Force Vue update, um sicherzustellen, dass die Gruppenübersicht aktualisiert wird
|
||||
this.$forceUpdate();
|
||||
|
||||
// Aktualisiere auch die groupId des Teilnehmers basierend auf der neuen groupNumber
|
||||
if (groupNumber !== null) {
|
||||
const newGroup = this.groups.find(g => g.groupNumber === groupNumber);
|
||||
if (newGroup) {
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupId', newGroup.groupId);
|
||||
} else {
|
||||
participant.groupId = newGroup.groupId;
|
||||
}
|
||||
} else {
|
||||
// Gruppe nicht gefunden, setze groupId auf null
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupId', null);
|
||||
} else {
|
||||
participant.groupId = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// groupNumber ist null, also auch groupId auf null
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupId', null);
|
||||
} else {
|
||||
participant.groupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lade Matches neu, da sich die Gruppenzuordnung geändert hat
|
||||
await this.loadMatches();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Gruppe:', error);
|
||||
// Bei Fehler: Lade Daten neu und setze groupNumber zurück
|
||||
participant.groupNumber = participant.groupId ? this.groups.find(g => g.groupId === participant.groupId)?.groupNumber || null : null;
|
||||
await this.loadTournamentData();
|
||||
this.$forceUpdate();
|
||||
// Bei Fehler: Setze groupNumber zurück auf den alten Wert
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupNumber', oldGroupNumber);
|
||||
} else {
|
||||
participant.groupNumber = oldGroupNumber;
|
||||
}
|
||||
// Lade Gruppen neu, um sicherzustellen, dass groupId korrekt ist
|
||||
try {
|
||||
const gRes = await apiClient.get('/tournament/groups', {
|
||||
params: {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate
|
||||
}
|
||||
});
|
||||
const groupsData = Array.isArray(gRes.data)
|
||||
? gRes.data
|
||||
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
|
||||
this.groups = [...groupsData];
|
||||
// Setze groupId basierend auf dem alten groupNumber
|
||||
if (oldGroupNumber !== null) {
|
||||
const oldGroup = this.groups.find(g => g.groupNumber === oldGroupNumber);
|
||||
const oldGroupId = oldGroup ? oldGroup.groupId : null;
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupId', oldGroupId);
|
||||
} else {
|
||||
participant.groupId = oldGroupId;
|
||||
}
|
||||
} else {
|
||||
if (this.$set) {
|
||||
this.$set(participant, 'groupId', null);
|
||||
} else {
|
||||
participant.groupId = null;
|
||||
}
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.error('Fehler beim Neuladen der Gruppen:', loadError);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2407,6 +2744,7 @@ export default {
|
||||
}
|
||||
await apiClient.post('/tournament/participant', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
classId: classId,
|
||||
participant: participant.clubMemberId
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user