Implement tournament pairing functionality and enhance participant management
- Introduced new endpoints for managing tournament pairings, including creating, updating, and deleting pairings. - Updated the tournament service to handle pairing logic, ensuring validation for participants and preventing duplicate pairings. - Enhanced participant management by adding class-based checks for gender and age restrictions when adding participants. - Updated the tournament controller and routes to support the new pairing features and improved participant handling. - Added localization support for new UI elements related to pairings in the frontend, enhancing user experience.
This commit is contained in:
377
frontend/src/components/tournament/TournamentClassList.vue
Normal file
377
frontend/src/components/tournament/TournamentClassList.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<section class="tournament-classes">
|
||||
<h4>{{ $t('tournaments.classes') }}</h4>
|
||||
<div class="classes-content">
|
||||
<div v-if="tournamentClasses.length === 0" class="no-classes-message">
|
||||
<p>{{ $t('tournaments.noClassesYet') }}</p>
|
||||
</div>
|
||||
<div v-else class="classes-list">
|
||||
<div v-for="classItem in tournamentClasses" :key="classItem.id" class="class-item">
|
||||
<template v-if="editingClassId === classItem.id">
|
||||
<!-- Bearbeitungsfelder werden weiter unten verwendet -->
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="class-name-label">
|
||||
{{ classItem.name }}
|
||||
<span class="class-type-inline" :class="{ 'doubles': classItem.isDoubles }">
|
||||
({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="classItem.gender" class="class-gender-badge" :class="'gender-' + classItem.gender">
|
||||
{{ classItem.gender === 'male' ? $t('members.genderMale') : classItem.gender === 'female' ? $t('members.genderFemale') : $t('tournaments.genderMixed') }}
|
||||
</span>
|
||||
<span v-if="classItem.minBirthYear" class="class-birth-year-badge">
|
||||
≥ {{ classItem.minBirthYear }}
|
||||
</span>
|
||||
<button @click.stop="editClass(classItem)" class="btn-edit-small" :title="$t('tournaments.edit')">✏️</button>
|
||||
<button @click.stop="deleteClass(classItem)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑️</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Gemeinsame Eingabefelder für Hinzufügen und Bearbeiten -->
|
||||
<div class="add-class" :class="{ 'editing': editingClassId !== null }">
|
||||
<input
|
||||
type="text"
|
||||
:value="isEditing ? localEditingClassName : localNewClassName"
|
||||
@input="handleNameInput($event)"
|
||||
:placeholder="$t('tournaments.className')"
|
||||
class="class-name-input"
|
||||
@keyup.enter="isEditing ? saveClassEdit(getEditingClassItem()) : addClass()"
|
||||
@keyup.esc="isEditing ? cancelClassEdit() : null"
|
||||
ref="classInput"
|
||||
/>
|
||||
<label class="class-doubles-checkbox" @mousedown.prevent>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isEditing ? localEditingClassIsDoubles : localNewClassIsDoubles"
|
||||
@change="handleDoublesChange($event)"
|
||||
@mousedown.stop
|
||||
/>
|
||||
<span>{{ $t('tournaments.doubles') }}</span>
|
||||
</label>
|
||||
<select
|
||||
:value="isEditing ? localEditingClassGender : localNewClassGender"
|
||||
@change="handleGenderChange($event)"
|
||||
class="class-gender-select"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option :value="null">{{ $t('tournaments.genderAll') }}</option>
|
||||
<option value="male">{{ $t('members.genderMale') }}</option>
|
||||
<option value="female">{{ $t('members.genderFemale') }}</option>
|
||||
<option value="mixed">{{ $t('tournaments.genderMixed') }}</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
:value="isEditing ? localEditingClassMaxBirthYear : localNewClassMaxBirthYear"
|
||||
@input="handleMaxBirthYearInput($event)"
|
||||
:min="currentYear - 18"
|
||||
:max="currentYear - 6"
|
||||
:placeholder="$t('tournaments.minBirthYear')"
|
||||
class="class-birth-year-input"
|
||||
@mousedown.stop
|
||||
/>
|
||||
<button
|
||||
v-if="isEditing"
|
||||
@click="saveClassEdit(getEditingClassItem())"
|
||||
class="btn-save-small"
|
||||
:title="$t('tournaments.save')"
|
||||
>✓</button>
|
||||
<button
|
||||
v-if="isEditing"
|
||||
@click="cancelClassEdit"
|
||||
class="btn-cancel-small"
|
||||
:title="$t('tournaments.cancel')"
|
||||
>✕</button>
|
||||
<button
|
||||
v-else
|
||||
@click="addClass"
|
||||
class="btn-add"
|
||||
>{{ $t('tournaments.addClass') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TournamentClassList',
|
||||
props: {
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
showClasses: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editingClassId: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
editingClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editingClassIsDoubles: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editingClassGender: {
|
||||
type: [String, null],
|
||||
default: null
|
||||
},
|
||||
editingClassMaxBirthYear: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
newClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
newClassIsDoubles: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
newClassGender: {
|
||||
type: [String, null],
|
||||
default: null
|
||||
},
|
||||
newClassMaxBirthYear: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'edit-class',
|
||||
'save-class-edit',
|
||||
'cancel-class-edit',
|
||||
'delete-class',
|
||||
'add-class',
|
||||
'add-class-error',
|
||||
'update:newClassName',
|
||||
'update:newClassIsDoubles',
|
||||
'update:newClassGender',
|
||||
'update:editingClassName',
|
||||
'update:editingClassIsDoubles',
|
||||
'update:editingClassGender',
|
||||
'update:editingClassMaxBirthYear',
|
||||
'update:newClassMaxBirthYear',
|
||||
'handle-class-input-blur'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
localEditingClassName: '',
|
||||
localEditingClassIsDoubles: false,
|
||||
localEditingClassGender: null,
|
||||
localEditingClassMaxBirthYear: null,
|
||||
localNewClassName: '',
|
||||
localNewClassIsDoubles: false,
|
||||
localNewClassGender: null,
|
||||
localNewClassMaxBirthYear: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
editingClassName(newVal) {
|
||||
this.localEditingClassName = newVal;
|
||||
},
|
||||
editingClassIsDoubles(newVal) {
|
||||
this.localEditingClassIsDoubles = newVal;
|
||||
},
|
||||
editingClassGender(newVal) {
|
||||
this.localEditingClassGender = newVal;
|
||||
},
|
||||
editingClassMaxBirthYear(newVal) {
|
||||
this.localEditingClassMaxBirthYear = newVal;
|
||||
},
|
||||
newClassName(newVal) {
|
||||
this.localNewClassName = newVal;
|
||||
},
|
||||
newClassIsDoubles(newVal) {
|
||||
this.localNewClassIsDoubles = newVal;
|
||||
},
|
||||
newClassGender(newVal) {
|
||||
this.localNewClassGender = newVal;
|
||||
},
|
||||
newClassMaxBirthYear(newVal) {
|
||||
this.localNewClassMaxBirthYear = newVal;
|
||||
},
|
||||
editingClassId(newVal) {
|
||||
// Wenn eine neue Klasse zum Bearbeiten ausgewählt wird, aktualisiere die lokalen Werte
|
||||
if (newVal !== null) {
|
||||
const classItem = this.tournamentClasses.find(c => c.id === newVal);
|
||||
if (classItem) {
|
||||
this.localEditingClassName = classItem.name;
|
||||
this.localEditingClassIsDoubles = Boolean(classItem.isDoubles);
|
||||
this.localEditingClassGender = classItem.gender || null;
|
||||
this.localEditingClassMaxBirthYear = classItem.minBirthYear || null;
|
||||
}
|
||||
// Bearbeitungsmodus: Fokussiere das Eingabefeld
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.classInput) {
|
||||
this.$refs.classInput.focus();
|
||||
this.$refs.classInput.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Wenn Bearbeitung abgebrochen wird, setze die Werte zurück
|
||||
this.localEditingClassName = '';
|
||||
this.localEditingClassIsDoubles = false;
|
||||
this.localEditingClassGender = null;
|
||||
this.localEditingClassMaxBirthYear = null;
|
||||
// Felder für Hinzufügen zurücksetzen
|
||||
this.localNewClassName = '';
|
||||
this.localNewClassIsDoubles = false;
|
||||
this.localNewClassGender = null;
|
||||
this.localNewClassMaxBirthYear = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentYear() {
|
||||
return new Date().getFullYear();
|
||||
},
|
||||
isEditing() {
|
||||
return this.editingClassId !== null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Initialisiere lokale Werte aus Props
|
||||
this.localEditingClassName = this.editingClassName;
|
||||
this.localEditingClassIsDoubles = this.editingClassIsDoubles;
|
||||
this.localEditingClassGender = this.editingClassGender;
|
||||
this.localEditingClassMaxBirthYear = this.editingClassMaxBirthYear;
|
||||
this.localNewClassName = this.newClassName;
|
||||
this.localNewClassIsDoubles = this.newClassIsDoubles;
|
||||
this.localNewClassGender = this.newClassGender;
|
||||
this.localNewClassMaxBirthYear = this.newClassMaxBirthYear;
|
||||
},
|
||||
methods: {
|
||||
editClass(classItem) {
|
||||
this.$emit('edit-class', classItem);
|
||||
},
|
||||
saveClassEdit(classItem) {
|
||||
this.$emit('save-class-edit', classItem);
|
||||
},
|
||||
cancelClassEdit() {
|
||||
this.$emit('cancel-class-edit');
|
||||
},
|
||||
deleteClass(classItem) {
|
||||
this.$emit('delete-class', classItem);
|
||||
},
|
||||
addClass() {
|
||||
// Validiere zuerst in der Komponente
|
||||
const className = this.localNewClassName;
|
||||
|
||||
if (!className || !className.trim()) {
|
||||
// Emittiere ein Error-Event, damit der Parent die Fehlermeldung anzeigen kann
|
||||
this.$emit('add-class-error', 'Bitte geben Sie einen Klassennamen ein!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stelle sicher, dass die Werte synchronisiert sind, bevor das Event emittiert wird
|
||||
this.$emit('update:newClassName', this.localNewClassName);
|
||||
this.$emit('update:newClassIsDoubles', this.localNewClassIsDoubles);
|
||||
this.$emit('update:newClassGender', this.localNewClassGender);
|
||||
this.$emit('update:newClassMaxBirthYear', this.localNewClassMaxBirthYear);
|
||||
|
||||
const data = {
|
||||
name: this.localNewClassName,
|
||||
isDoubles: this.localNewClassIsDoubles,
|
||||
gender: this.localNewClassGender,
|
||||
minBirthYear: this.localNewClassMaxBirthYear
|
||||
};
|
||||
// Emittiere das Event mit den aktuellen Werten als Parameter
|
||||
this.$emit('add-class', data);
|
||||
|
||||
// Felder zurücksetzen nach dem Hinzufügen
|
||||
this.localNewClassName = '';
|
||||
this.localNewClassIsDoubles = false;
|
||||
this.localNewClassGender = null;
|
||||
this.localNewClassMaxBirthYear = null;
|
||||
},
|
||||
getEditingClassItem() {
|
||||
return this.tournamentClasses.find(c => c.id === this.editingClassId);
|
||||
},
|
||||
handleNameInput(event) {
|
||||
const value = event.target.value;
|
||||
if (this.isEditing) {
|
||||
this.localEditingClassName = value;
|
||||
this.$emit('update:editingClassName', value);
|
||||
} else {
|
||||
this.localNewClassName = value;
|
||||
this.$emit('update:newClassName', value);
|
||||
}
|
||||
},
|
||||
handleDoublesChange(event) {
|
||||
const checked = event.target.checked;
|
||||
if (this.isEditing) {
|
||||
this.localEditingClassIsDoubles = checked;
|
||||
this.$emit('update:editingClassIsDoubles', checked);
|
||||
} else {
|
||||
this.localNewClassIsDoubles = checked;
|
||||
this.$emit('update:newClassIsDoubles', checked);
|
||||
}
|
||||
},
|
||||
handleGenderChange(event) {
|
||||
const value = event.target.value === 'null' ? null : event.target.value;
|
||||
if (this.isEditing) {
|
||||
this.localEditingClassGender = value;
|
||||
this.$emit('update:editingClassGender', value);
|
||||
} else {
|
||||
this.localNewClassGender = value;
|
||||
this.$emit('update:newClassGender', value);
|
||||
}
|
||||
},
|
||||
handleMaxBirthYearInput(event) {
|
||||
const value = event.target.value ? parseInt(event.target.value) : null;
|
||||
if (this.isEditing) {
|
||||
this.localEditingClassMaxBirthYear = value;
|
||||
this.$emit('update:editingClassMaxBirthYear', value);
|
||||
} else {
|
||||
this.localNewClassMaxBirthYear = value;
|
||||
this.$emit('update:newClassMaxBirthYear', value);
|
||||
}
|
||||
},
|
||||
handleClassInputBlur(event, classItem) {
|
||||
this.$emit('handle-class-input-blur', event, classItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.class-type-inline {
|
||||
color: #4caf50;
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.class-birth-year-input {
|
||||
padding: 0.4rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
min-width: 120px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.class-birth-year-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
margin-left: 0.5rem;
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-class.editing {
|
||||
background-color: #fff3cd;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffc107;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<section v-if="selectedDate && selectedDate !== 'new'" class="class-selection-section">
|
||||
<div class="participants-class-filter">
|
||||
<label>
|
||||
{{ $t('tournaments.showClass') }}:
|
||||
<select :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" class="class-filter-select">
|
||||
<option :value="null">{{ $t('tournaments.allClasses') }}</option>
|
||||
<option v-for="classItem in tournamentClasses" :key="classItem.id" :value="classItem.id">
|
||||
{{ classItem.name }} ({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</option>
|
||||
<option value="__none__">{{ $t('tournaments.withoutClass') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TournamentClassSelector',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
selectedDate: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue']
|
||||
};
|
||||
</script>
|
||||
|
||||
147
frontend/src/components/tournament/TournamentConfigTab.vue
Normal file
147
frontend/src/components/tournament/TournamentConfigTab.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="tab-content" key="config-tab">
|
||||
<div class="tournament-info">
|
||||
<label>
|
||||
{{ $t('tournaments.name') }}:
|
||||
<input type="text" :value="tournamentName" @input="$emit('update:tournamentName', $event.target.value)" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('tournaments.date') }}:
|
||||
<input type="date" :value="tournamentDate" @change="$emit('update:tournamentDate', $event.target.value)" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('tournaments.winningSets') }}:
|
||||
<input type="number" :value="winningSets" @input="$emit('update:winningSets', parseInt($event.target.value))" min="1" />
|
||||
</label>
|
||||
<button @click="$emit('generate-pdf')" class="btn-primary" style="margin-top: 1rem;">{{ $t('tournaments.exportPDF') }}</button>
|
||||
</div>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" :checked="isGroupTournament" @change="$emit('update:isGroupTournament', $event.target.checked)" />
|
||||
<span>{{ $t('tournaments.playInGroups') }}</span>
|
||||
</label>
|
||||
<TournamentClassList
|
||||
:tournament-classes="tournamentClasses"
|
||||
:show-classes="showClasses"
|
||||
:editing-class-id="editingClassId"
|
||||
:editing-class-name="editingClassName"
|
||||
:editing-class-is-doubles="editingClassIsDoubles"
|
||||
:editing-class-gender="editingClassGender"
|
||||
:editing-class-min-birth-year="editingClassMinBirthYear"
|
||||
:new-class-name="newClassName"
|
||||
:new-class-is-doubles="newClassIsDoubles"
|
||||
:new-class-gender="newClassGender"
|
||||
:new-class-min-birth-year="newClassMinBirthYear"
|
||||
@edit-class="$emit('edit-class', $event)"
|
||||
@save-class-edit="$emit('save-class-edit', $event)"
|
||||
@cancel-class-edit="$emit('cancel-class-edit')"
|
||||
@delete-class="$emit('delete-class', $event)"
|
||||
@add-class="(data) => $emit('add-class', data)"
|
||||
@add-class-error="$emit('add-class-error', $event)"
|
||||
@handle-class-input-blur="$emit('handle-class-input-blur', $event[0], $event[1])"
|
||||
@update:editingClassName="$emit('update:editingClassName', $event)"
|
||||
@update:editingClassIsDoubles="$emit('update:editingClassIsDoubles', $event)"
|
||||
@update:editingClassGender="$emit('update:editingClassGender', $event)"
|
||||
@update:editingClassMinBirthYear="$emit('update:editingClassMinBirthYear', $event)"
|
||||
@update:newClassName="$emit('update:newClassName', $event)"
|
||||
@update:newClassIsDoubles="$emit('update:newClassIsDoubles', $event)"
|
||||
@update:newClassGender="$emit('update:newClassGender', $event)"
|
||||
@update:newClassMinBirthYear="$emit('update:newClassMinBirthYear', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TournamentClassList from './TournamentClassList.vue';
|
||||
|
||||
export default {
|
||||
name: 'TournamentConfigTab',
|
||||
components: {
|
||||
TournamentClassList
|
||||
},
|
||||
props: {
|
||||
tournamentName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
tournamentDate: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
winningSets: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
isGroupTournament: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
showClasses: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editingClassId: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
editingClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editingClassIsDoubles: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editingClassGender: {
|
||||
type: [String, null],
|
||||
default: null
|
||||
},
|
||||
editingClassMinBirthYear: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
newClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
newClassIsDoubles: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
newClassGender: {
|
||||
type: [String, null],
|
||||
default: null
|
||||
},
|
||||
newClassMinBirthYear: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:tournamentName',
|
||||
'update:tournamentDate',
|
||||
'update:winningSets',
|
||||
'update:isGroupTournament',
|
||||
'generate-pdf',
|
||||
'edit-class',
|
||||
'save-class-edit',
|
||||
'cancel-class-edit',
|
||||
'delete-class',
|
||||
'add-class',
|
||||
'add-class-error',
|
||||
'handle-class-input-blur',
|
||||
'update:editingClassName',
|
||||
'update:editingClassIsDoubles',
|
||||
'update:editingClassGender',
|
||||
'update:editingClassMinBirthYear',
|
||||
'update:newClassName',
|
||||
'update:newClassIsDoubles',
|
||||
'update:newClassGender',
|
||||
'update:newClassMinBirthYear'
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
349
frontend/src/components/tournament/TournamentGroupsTab.vue
Normal file
349
frontend/src/components/tournament/TournamentGroupsTab.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<TournamentClassSelector
|
||||
v-if="selectedDate && selectedDate !== 'new'"
|
||||
:model-value="selectedViewClass"
|
||||
:tournament-classes="tournamentClasses"
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section v-if="isGroupTournament" class="group-controls">
|
||||
<label>
|
||||
{{ $t('tournaments.advancersPerGroup') }}:
|
||||
<input type="number" :value="advancingPerGroup" @input="$emit('update:advancingPerGroup', parseInt($event.target.value))" min="1" @change="$emit('modus-change')" />
|
||||
</label>
|
||||
<label style="margin-left:1em">
|
||||
{{ $t('tournaments.maxGroupSize') }}:
|
||||
<input type="number" :value="maxGroupSize" @input="$emit('update:maxGroupSize', parseInt($event.target.value))" min="1" />
|
||||
</label>
|
||||
|
||||
<div v-if="selectedViewClass !== null && selectedViewClass !== undefined" class="groups-per-class">
|
||||
<h4>{{ $t('tournaments.groupsPerClass') }}</h4>
|
||||
<p class="groups-per-class-hint">{{ $t('tournaments.groupsPerClassHint') }}</p>
|
||||
<div class="class-group-config">
|
||||
<label class="class-group-label">
|
||||
<span class="class-group-name">
|
||||
{{ selectedViewClass === '__none__' ? $t('tournaments.withoutClass') : getClassName(selectedViewClass) }}
|
||||
</span>
|
||||
<span v-if="selectedViewClass !== '__none__'" class="class-group-type" :class="{ 'doubles': isClassDoubles(selectedViewClass) }">
|
||||
({{ isClassDoubles(selectedViewClass) ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="groupsPerClassInput"
|
||||
@input="$emit('update:groupsPerClassInput', parseInt($event.target.value))"
|
||||
min="0"
|
||||
@change="$emit('group-count-change')"
|
||||
class="class-group-input"
|
||||
:placeholder="$t('tournaments.numberOfGroups')"
|
||||
/>
|
||||
<span class="class-group-unit">{{ $t('tournaments.group') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<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')" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button @click="$emit('create-groups')">{{ $t('tournaments.createGroups') }}</button>
|
||||
<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">
|
||||
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
|
||||
<template v-for="(classGroups, classId) in groupsByClass" :key="classId">
|
||||
<template v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))">
|
||||
<div v-if="classId !== 'null' && classId !== 'undefined'" class="class-section">
|
||||
<h4 class="class-header">
|
||||
{{ getClassName(classId) }}
|
||||
</h4>
|
||||
</div>
|
||||
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
|
||||
<h4>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.index') }}</th>
|
||||
<th>{{ $t('tournaments.position') }}</th>
|
||||
<th>{{ $t('tournaments.player') }}</th>
|
||||
<th>{{ $t('tournaments.points') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
<th>{{ $t('tournaments.diff') }}</th>
|
||||
<th>{{ $t('tournaments.pointsRatio') }}</th>
|
||||
<th v-for="(opponent, idx) in groupRankings[group.groupId]" :key="`opp-${opponent.id}`">
|
||||
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
|
||||
</th>
|
||||
<th>{{ $t('tournaments.livePosition') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(pl, idx) in groupRankings[group.groupId]" :key="pl.id">
|
||||
<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.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) }})
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx, 'active-group-cell': activeGroupCells.includes(`match-${pl.id}-${opponent.id}`), 'diagonal-cell': idx === oppIdx }]"
|
||||
@click="idx !== oppIdx ? $emit('highlight-match', pl.id, opponent.id, group.groupId) : null">
|
||||
<span v-if="idx === oppIdx" class="diagonal"></span>
|
||||
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
|
||||
:class="getMatchCellClasses(pl.id, opponent.id, group.groupId)">
|
||||
{{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
|
||||
</span>
|
||||
<span v-else class="no-match">-</span>
|
||||
</td>
|
||||
<td>{{ getLivePosition(pl.id, group.groupId) }}.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div class="reset-controls" style="margin-top:1rem">
|
||||
<button @click="$emit('reset-matches')" class="trash-btn">
|
||||
🗑️ {{ $t('tournaments.resetGroupMatches') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TournamentClassSelector from './TournamentClassSelector.vue';
|
||||
|
||||
export default {
|
||||
name: 'TournamentGroupsTab',
|
||||
components: {
|
||||
TournamentClassSelector
|
||||
},
|
||||
props: {
|
||||
selectedDate: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
selectedViewClass: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isGroupTournament: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
advancingPerGroup: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxGroupSize: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
groupsPerClassInput: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
numberOfGroups: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
groupsByClass: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
groupRankings: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
activeGroupCells: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
matches: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:selectedViewClass',
|
||||
'update:advancingPerGroup',
|
||||
'update:maxGroupSize',
|
||||
'update:groupsPerClassInput',
|
||||
'update:numberOfGroups',
|
||||
'modus-change',
|
||||
'group-count-change',
|
||||
'create-groups',
|
||||
'randomize-groups',
|
||||
'reset-groups',
|
||||
'reset-matches',
|
||||
'highlight-match'
|
||||
],
|
||||
methods: {
|
||||
shouldShowClass(classId) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return true;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
return classId === null;
|
||||
}
|
||||
// Vergleiche als Zahlen, um String/Number-Probleme zu vermeiden
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
const compareId = Number(classId);
|
||||
// Prüfe auf NaN (falls Parsing fehlschlägt)
|
||||
if (Number.isNaN(selectedId) || Number.isNaN(compareId)) {
|
||||
return false;
|
||||
}
|
||||
return selectedId === compareId;
|
||||
},
|
||||
getClassName(classId) {
|
||||
if (classId === null || classId === '__none__' || classId === 'null' || classId === 'undefined' || classId === undefined) {
|
||||
return this.$t('tournaments.withoutClass');
|
||||
}
|
||||
try {
|
||||
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
|
||||
const classItem = this.tournamentClasses.find(c => c.id === classIdNum);
|
||||
return classItem ? classItem.name : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
isClassDoubles(classId) {
|
||||
if (classId === null || classId === '__none__' || classId === 'null' || classId === undefined) {
|
||||
return false;
|
||||
}
|
||||
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
|
||||
const classItem = this.tournamentClasses.find(c => c.id === classIdNum);
|
||||
return classItem ? Boolean(classItem.isDoubles) : false;
|
||||
},
|
||||
getMatchLiveResult(player1Id, player2Id, groupId) {
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
((m.player1.id === player1Id && m.player2.id === player2Id) ||
|
||||
(m.player1.id === player2Id && m.player2.id === player1Id))
|
||||
);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
// Berechne aktuelle Sätze aus tournamentResults
|
||||
let sets1 = 0, sets2 = 0;
|
||||
if (match.tournamentResults && match.tournamentResults.length > 0) {
|
||||
match.tournamentResults.forEach(result => {
|
||||
if (result.pointsPlayer1 > result.pointsPlayer2) {
|
||||
sets1++;
|
||||
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
|
||||
sets2++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sets1,
|
||||
sets2,
|
||||
isFinished: match.isFinished,
|
||||
player1Won: sets1 > sets2,
|
||||
player2Won: sets2 > sets1
|
||||
};
|
||||
},
|
||||
getMatchDisplayText(player1Id, player2Id, groupId) {
|
||||
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
|
||||
if (!liveResult) return '-';
|
||||
return `${liveResult.sets1}:${liveResult.sets2}`;
|
||||
},
|
||||
getMatchCellClasses(player1Id, player2Id, groupId) {
|
||||
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
|
||||
if (!liveResult) return ['no-match'];
|
||||
|
||||
const classes = ['match-result'];
|
||||
|
||||
if (liveResult.isFinished) {
|
||||
if (liveResult.player1Won) {
|
||||
classes.push('match-finished-win');
|
||||
} else if (liveResult.player2Won) {
|
||||
classes.push('match-finished-loss');
|
||||
} else {
|
||||
classes.push('match-finished-tie');
|
||||
}
|
||||
} else {
|
||||
classes.push('match-live');
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
getLivePosition(playerId, groupId) {
|
||||
const groupPlayers = this.groupRankings[groupId] || [];
|
||||
const liveStats = groupPlayers.map(player => {
|
||||
let livePoints = player.points || 0;
|
||||
let liveSetsWon = player.setsWon || 0;
|
||||
let liveSetsLost = player.setsLost || 0;
|
||||
|
||||
const playerMatches = this.matches.filter(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
(m.player1.id === player.id || m.player2.id === player.id) &&
|
||||
!m.isFinished &&
|
||||
m.tournamentResults && m.tournamentResults.length > 0
|
||||
);
|
||||
|
||||
playerMatches.forEach(match => {
|
||||
const isPlayer1 = match.player1.id === player.id;
|
||||
match.tournamentResults.forEach(result => {
|
||||
if (isPlayer1) {
|
||||
if (result.pointsPlayer1 > result.pointsPlayer2) {
|
||||
livePoints += 1;
|
||||
liveSetsWon += 1;
|
||||
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
|
||||
liveSetsLost += 1;
|
||||
}
|
||||
} else {
|
||||
if (result.pointsPlayer2 > result.pointsPlayer1) {
|
||||
livePoints += 1;
|
||||
liveSetsWon += 1;
|
||||
} else if (result.pointsPlayer1 > result.pointsPlayer2) {
|
||||
liveSetsLost += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
id: player.id,
|
||||
points: livePoints,
|
||||
setsWon: liveSetsWon,
|
||||
setsLost: liveSetsLost,
|
||||
setDiff: liveSetsWon - liveSetsLost
|
||||
};
|
||||
});
|
||||
|
||||
liveStats.sort((a, b) => {
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
if (b.setDiff !== a.setDiff) return b.setDiff - a.setDiff;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const position = liveStats.findIndex(p => p.id === playerId) + 1;
|
||||
return position || groupPlayers.findIndex(p => p.id === playerId) + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
630
frontend/src/components/tournament/TournamentParticipantsTab.vue
Normal file
630
frontend/src/components/tournament/TournamentParticipantsTab.vue
Normal file
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<TournamentClassSelector
|
||||
v-if="selectedDate && selectedDate !== 'new'"
|
||||
:model-value="selectedViewClass"
|
||||
:tournament-classes="tournamentClasses"
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section class="participants">
|
||||
<h4>{{ $t('tournaments.participants') }}</h4>
|
||||
<div class="participants-content">
|
||||
<div class="participants-layout">
|
||||
<div class="add-participant" v-if="canAssignClass">
|
||||
<div class="add-participant-section">
|
||||
<h5>{{ $t('tournaments.addClubMember') }}</h5>
|
||||
<div class="add-participant-row">
|
||||
<select :value="selectedMember" @change="$emit('update:selectedMember', $event.target.value ? parseInt($event.target.value) : null)" class="member-select">
|
||||
<option :value="null">{{ $t('tournaments.selectParticipant') }}</option>
|
||||
<option v-for="member in filteredClubMembers" :key="member.id" :value="member.id">
|
||||
{{ member.firstName }}
|
||||
{{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="$emit('add-participant')" class="btn-add" :disabled="!selectedMember">{{ $t('tournaments.add') }}</button>
|
||||
<button v-if="hasTrainingToday && !allowsExternal" @click="$emit('load-participants-from-training')" class="training-btn">
|
||||
📅 {{ $t('tournaments.loadFromTraining') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="allowsExternal" class="add-participant-section">
|
||||
<h5>{{ $t('tournaments.addExternalParticipant') }}</h5>
|
||||
<div class="add-participant-row">
|
||||
<input
|
||||
type="text"
|
||||
:value="newExternalParticipant.firstName"
|
||||
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, firstName: $event.target.value })"
|
||||
:placeholder="$t('tournaments.firstName')"
|
||||
class="external-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
:value="newExternalParticipant.lastName"
|
||||
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, lastName: $event.target.value })"
|
||||
:placeholder="$t('tournaments.lastName')"
|
||||
class="external-input"
|
||||
/>
|
||||
<select
|
||||
:value="newExternalParticipant.gender"
|
||||
@change="$emit('update:newExternalParticipant', { ...newExternalParticipant, gender: $event.target.value })"
|
||||
class="external-input"
|
||||
>
|
||||
<option value="unknown">{{ $t('members.genderUnknown') }}</option>
|
||||
<option value="male">{{ $t('members.genderMale') }}</option>
|
||||
<option value="female">{{ $t('members.genderFemale') }}</option>
|
||||
<option value="diverse">{{ $t('members.genderDiverse') }}</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
:value="newExternalParticipant.club"
|
||||
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, club: $event.target.value })"
|
||||
:placeholder="$t('tournaments.club') + ' (' + $t('tournaments.optional') + ')'"
|
||||
class="external-input"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
:value="newExternalParticipant.birthDate"
|
||||
@input="$emit('update:newExternalParticipant', { ...newExternalParticipant, birthDate: $event.target.value })"
|
||||
:placeholder="$t('tournaments.birthdate') + ' (' + $t('tournaments.optional') + ')'"
|
||||
class="external-input"
|
||||
/>
|
||||
<button
|
||||
@click="$emit('add-external-participant')"
|
||||
class="btn-add"
|
||||
:disabled="!newExternalParticipant.firstName || !newExternalParticipant.lastName"
|
||||
>
|
||||
{{ $t('tournaments.add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="participants-table-container">
|
||||
<!-- Teilnehmer nach Klassen gruppiert - nur angezeigte Klasse -->
|
||||
<template v-for="classItem in tournamentClasses" :key="classItem.id">
|
||||
<div v-if="shouldShowClass(classItem.id)" class="participants-class-section">
|
||||
<h5 class="participants-class-header">
|
||||
{{ classItem.name }}
|
||||
<span class="class-type-badge-small" :class="{ 'doubles': classItem.isDoubles }">
|
||||
({{ classItem.isDoubles ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</span>
|
||||
</h5>
|
||||
<table class="participants-table participants-table-header">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="participant-seeded-cell">{{ $t('tournaments.seeded') }}</th>
|
||||
<th class="participant-name">{{ $t('tournaments.name') }}</th>
|
||||
<th v-if="allowsExternal" class="participant-gender-cell">{{ $t('members.gender') }}</th>
|
||||
<th v-if="allowsExternal" class="participant-club-cell">{{ $t('tournaments.club') }}</th>
|
||||
<th v-if="isGroupTournament" class="participant-group-cell">{{ $t('tournaments.group') }}</th>
|
||||
<th class="participant-action-cell">{{ $t('tournaments.action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div class="participants-table-body-wrapper">
|
||||
<table class="participants-table participants-table-body">
|
||||
<tbody>
|
||||
<tr v-for="participant in getParticipantsForClass(classItem.id)" :key="participant.id" class="participant-item">
|
||||
<td class="participant-seeded-cell">
|
||||
<label class="seeded-checkbox-label">
|
||||
<input type="checkbox" :checked="participant.seeded" @change="$emit('update-participant-seeded', participant, $event)" />
|
||||
</label>
|
||||
</td>
|
||||
<td class="participant-name">
|
||||
<template v-if="participant.member">
|
||||
{{ participant.member.firstName || $t('tournaments.unknown') }}
|
||||
{{ participant.member.lastName || '' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ participant.firstName || $t('tournaments.unknown') }}
|
||||
{{ participant.lastName || '' }}
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="allowsExternal" class="participant-gender-cell">
|
||||
<template v-if="participant.member">
|
||||
<span class="gender-symbol" :class="'gender-' + (participant.member.gender || 'unknown')" :title="labelGender(participant.member.gender)">
|
||||
{{ genderSymbol(participant.member.gender) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="gender-symbol" :class="'gender-' + (participant.gender || 'unknown')" :title="labelGender(participant.gender)">
|
||||
{{ genderSymbol(participant.gender) }}
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="allowsExternal" class="participant-club-cell">
|
||||
<template v-if="participant.member">
|
||||
<em>{{ $t('tournaments.clubMember') }}</em>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ participant.club || '–' }}
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="isGroupTournament" class="participant-group-cell">
|
||||
<select
|
||||
:value="participant.groupNumber"
|
||||
@change="$emit('update-participant-group', participant, $event)"
|
||||
class="group-select-small"
|
||||
>
|
||||
<option :value="null">–</option>
|
||||
<option v-for="group in getGroupsForClass(classItem.id)" :key="group.groupId" :value="group.groupNumber">
|
||||
{{ group.groupNumber }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="participant-action-cell">
|
||||
<button @click="$emit('remove-participant', participant)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Teilnehmer ohne Klasse -->
|
||||
<div v-if="shouldShowClass(null) && getParticipantsForClass(null).length > 0" class="participants-class-section">
|
||||
<h5 class="participants-class-header">{{ $t('tournaments.withoutClass') }}</h5>
|
||||
<table class="participants-table participants-table-header">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="participant-seeded-cell">{{ $t('tournaments.seeded') }}</th>
|
||||
<th class="participant-name">{{ $t('tournaments.name') }}</th>
|
||||
<th v-if="allowsExternal" class="participant-gender-cell">{{ $t('members.gender') }}</th>
|
||||
<th v-if="allowsExternal" class="participant-club-cell">{{ $t('tournaments.club') }}</th>
|
||||
<th class="participant-class-cell">{{ $t('tournaments.class') }}</th>
|
||||
<th v-if="isGroupTournament" class="participant-group-cell">{{ $t('tournaments.group') }}</th>
|
||||
<th class="participant-action-cell">{{ $t('tournaments.action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div class="participants-table-body-wrapper">
|
||||
<table class="participants-table participants-table-body">
|
||||
<tbody>
|
||||
<tr v-for="participant in getParticipantsForClass(null)" :key="participant.id" class="participant-item">
|
||||
<td class="participant-seeded-cell">
|
||||
<label class="seeded-checkbox-label">
|
||||
<input type="checkbox" :checked="participant.seeded" @change="$emit('update-participant-seeded', participant, $event)" />
|
||||
</label>
|
||||
</td>
|
||||
<td class="participant-name">
|
||||
<template v-if="participant.member">
|
||||
{{ participant.member.firstName || $t('tournaments.unknown') }}
|
||||
{{ participant.member.lastName || '' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ participant.firstName || $t('tournaments.unknown') }}
|
||||
{{ participant.lastName || '' }}
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="allowsExternal" class="participant-gender-cell">
|
||||
<template v-if="participant.member">
|
||||
<span class="gender-symbol" :class="'gender-' + (participant.member.gender || 'unknown')" :title="labelGender(participant.member.gender)">
|
||||
{{ genderSymbol(participant.member.gender) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="gender-symbol" :class="'gender-' + (participant.gender || 'unknown')" :title="labelGender(participant.gender)">
|
||||
{{ genderSymbol(participant.gender) }}
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="allowsExternal" class="participant-club-cell">
|
||||
<template v-if="participant.member">
|
||||
<em>{{ $t('tournaments.clubMember') }}</em>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ participant.club || '–' }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="participant-class-cell">
|
||||
<select
|
||||
:value="participant.classId"
|
||||
@change="$emit('update-participant-class', participant, $event)"
|
||||
class="class-select-small"
|
||||
>
|
||||
<option :value="null">–</option>
|
||||
<option v-for="classItem in tournamentClasses" :key="classItem.id" :value="classItem.id">
|
||||
{{ classItem.name }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td v-if="isGroupTournament" class="participant-group-cell">
|
||||
<select
|
||||
:value="participant.groupNumber"
|
||||
@change="$emit('update-participant-group', participant, $event)"
|
||||
class="group-select-small"
|
||||
>
|
||||
<option :value="null">–</option>
|
||||
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
|
||||
{{ group.groupNumber }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="participant-action-cell">
|
||||
<button @click="$emit('remove-participant', participant)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Doppel-Paarungen -->
|
||||
<section v-if="selectedViewClass !== null && selectedViewClass !== undefined && selectedViewClass !== '__none__' && isClassDoubles(selectedViewClass)" class="pairings-section">
|
||||
<div class="pairings-header" @click="$emit('toggle-pairings')">
|
||||
<h4>{{ $t('tournaments.pairings') }}</h4>
|
||||
<span class="collapse-icon" :class="{ 'expanded': showPairings }">▼</span>
|
||||
</div>
|
||||
<div v-show="showPairings" class="pairings-content">
|
||||
<div class="add-pairing">
|
||||
<h5>{{ $t('tournaments.addPairing') }}</h5>
|
||||
<div class="pairing-form">
|
||||
<select
|
||||
:value="newPairing.player1Id"
|
||||
@change="$emit('update:newPairing', { ...newPairing, player1Id: $event.target.value ? parseInt($event.target.value) : null })"
|
||||
class="pairing-player-select"
|
||||
>
|
||||
<option :value="null">{{ $t('tournaments.selectPlayer') }} 1</option>
|
||||
<option v-for="participant in getParticipantsForClass(selectedViewClass)" :key="participant.id" :value="participant.id">
|
||||
{{ participant.member ? (participant.member.firstName + ' ' + participant.member.lastName) : (participant.firstName + ' ' + participant.lastName) }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="pairing-separator">+</span>
|
||||
<select
|
||||
:value="newPairing.player2Id"
|
||||
@change="$emit('update:newPairing', { ...newPairing, player2Id: $event.target.value ? parseInt($event.target.value) : null })"
|
||||
class="pairing-player-select"
|
||||
>
|
||||
<option :value="null">{{ $t('tournaments.selectPlayer') }} 2</option>
|
||||
<option v-for="participant in getParticipantsForClass(selectedViewClass)" :key="participant.id" :value="participant.id">
|
||||
{{ participant.member ? (participant.member.firstName + ' ' + participant.member.lastName) : (participant.firstName + ' ' + participant.lastName) }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="pairing-seeded-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="newPairing.seeded"
|
||||
@change="$emit('update:newPairing', { ...newPairing, seeded: $event.target.checked })"
|
||||
/>
|
||||
{{ $t('tournaments.seeded') }}
|
||||
</label>
|
||||
<button
|
||||
@click="$emit('add-pairing')"
|
||||
class="btn-add"
|
||||
:disabled="!newPairing.player1Id || !newPairing.player2Id || newPairing.player1Id === newPairing.player2Id"
|
||||
>
|
||||
{{ $t('tournaments.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="random-pairing-section">
|
||||
<button @click="$emit('create-random-pairings')" class="btn-random-pairings">{{ $t('tournaments.randomPairings') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pairings-list">
|
||||
<table class="pairings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.player') }} 1</th>
|
||||
<th>{{ $t('tournaments.player') }} 2</th>
|
||||
<th>{{ $t('tournaments.seeded') }}</th>
|
||||
<th>{{ $t('tournaments.action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pairing in pairings" :key="pairing.id">
|
||||
<td>{{ getPairingPlayerName(pairing, 1) }}</td>
|
||||
<td>{{ getPairingPlayerName(pairing, 2) }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="pairing.seeded"
|
||||
@change="$emit('update-pairing-seeded', pairing, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="$emit('remove-pairing', pairing)" class="trash-btn-small" :title="$t('tournaments.delete')">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TournamentClassSelector from './TournamentClassSelector.vue';
|
||||
|
||||
export default {
|
||||
name: 'TournamentParticipantsTab',
|
||||
components: {
|
||||
TournamentClassSelector
|
||||
},
|
||||
props: {
|
||||
selectedDate: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
selectedViewClass: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
showParticipants: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showPairings: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canAssignClass: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
allowsExternal: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isGroupTournament: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
selectedMember: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
clubMembers: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
hasTrainingToday: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
newExternalParticipant: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
participants: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
externalParticipants: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
pairings: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
newPairing: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:selectedViewClass',
|
||||
'update:selectedMember',
|
||||
'add-participant',
|
||||
'load-participants-from-training',
|
||||
'update:newExternalParticipant',
|
||||
'add-external-participant',
|
||||
'update-participant-seeded',
|
||||
'update-participant-group',
|
||||
'update-participant-class',
|
||||
'remove-participant',
|
||||
'toggle-pairings',
|
||||
'update:newPairing',
|
||||
'add-pairing',
|
||||
'create-random-pairings',
|
||||
'update-pairing-seeded',
|
||||
'remove-pairing'
|
||||
],
|
||||
computed: {
|
||||
filteredClubMembers() {
|
||||
// Wenn keine Klasse ausgewählt ist, zeige alle Mitglieder
|
||||
if (!this.selectedViewClass || this.selectedViewClass === '__none__' || this.selectedViewClass === null) {
|
||||
return this.clubMembers;
|
||||
}
|
||||
|
||||
// Finde die ausgewählte Klasse
|
||||
const selectedClassId = Number(this.selectedViewClass);
|
||||
const selectedClass = this.tournamentClasses.find(c => c.id === selectedClassId);
|
||||
|
||||
// Wenn keine Klasse gefunden, zeige alle Mitglieder
|
||||
if (!selectedClass) {
|
||||
return this.clubMembers;
|
||||
}
|
||||
|
||||
// Filtere basierend auf Geschlechtsbeschränkung und Geburtsjahr
|
||||
const classGender = selectedClass.gender;
|
||||
const minBirthYear = selectedClass.minBirthYear;
|
||||
|
||||
return this.clubMembers.filter(member => {
|
||||
// Filtere nach Geschlecht
|
||||
const memberGender = member.gender || 'unknown';
|
||||
let genderMatch = true;
|
||||
|
||||
if (classGender) {
|
||||
// Wenn die Klasse "mixed" ist, erlaube alle Geschlechter
|
||||
if (classGender === 'mixed') {
|
||||
genderMatch = true;
|
||||
} else if (classGender === 'male') {
|
||||
genderMatch = memberGender === 'male';
|
||||
} else if (classGender === 'female') {
|
||||
genderMatch = memberGender === 'female';
|
||||
}
|
||||
}
|
||||
|
||||
// Filtere nach Geburtsjahr (geboren im Jahr X oder später, also >=)
|
||||
let birthYearMatch = true;
|
||||
if (minBirthYear && member.birthDate) {
|
||||
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
|
||||
let birthYear = null;
|
||||
if (member.birthDate.includes('-')) {
|
||||
// Format: YYYY-MM-DD
|
||||
birthYear = parseInt(member.birthDate.split('-')[0]);
|
||||
} else if (member.birthDate.includes('.')) {
|
||||
// Format: DD.MM.YYYY
|
||||
const parts = member.birthDate.split('.');
|
||||
if (parts.length === 3) {
|
||||
birthYear = parseInt(parts[2]);
|
||||
}
|
||||
}
|
||||
|
||||
if (birthYear && !isNaN(birthYear)) {
|
||||
// Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear
|
||||
birthYearMatch = birthYear >= minBirthYear;
|
||||
}
|
||||
}
|
||||
|
||||
return genderMatch && birthYearMatch;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shouldShowClass(classId) {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
|
||||
return classId === null;
|
||||
}
|
||||
const selectedId = Number(this.selectedViewClass);
|
||||
const compareId = Number(classId);
|
||||
if (Number.isNaN(selectedId) || Number.isNaN(compareId)) {
|
||||
return false;
|
||||
}
|
||||
return selectedId === compareId;
|
||||
},
|
||||
allParticipantsList() {
|
||||
const all = this.allowsExternal
|
||||
? [
|
||||
...this.participants.map(p => ({ ...p, isExternal: false })),
|
||||
...this.externalParticipants.map(p => ({ ...p, isExternal: true }))
|
||||
]
|
||||
: this.participants.map(p => ({ ...p, isExternal: false }));
|
||||
|
||||
const seen = new Set();
|
||||
return all.filter(p => {
|
||||
const key = p.id || `${p.clubMemberId || p.externalId || ''}_${p.classId || 'null'}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
getParticipantsForClass(classId) {
|
||||
return this.allParticipantsList().filter(p => {
|
||||
if (classId === null || classId === '__none__') {
|
||||
return p.classId === null || p.classId === undefined;
|
||||
}
|
||||
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
|
||||
const pClassIdNum = typeof p.classId === 'string' ? parseInt(p.classId) : p.classId;
|
||||
return pClassIdNum === classIdNum;
|
||||
}).sort((a, b) => {
|
||||
const firstNameA = (a.member?.firstName || a.firstName || '').toLowerCase();
|
||||
const firstNameB = (b.member?.firstName || b.firstName || '').toLowerCase();
|
||||
if (firstNameA !== firstNameB) {
|
||||
return firstNameA.localeCompare(firstNameB, 'de');
|
||||
}
|
||||
const lastNameA = (a.member?.lastName || a.lastName || '').toLowerCase();
|
||||
const lastNameB = (b.member?.lastName || b.lastName || '').toLowerCase();
|
||||
return lastNameA.localeCompare(lastNameB, 'de');
|
||||
});
|
||||
},
|
||||
getGroupsForClass(classId) {
|
||||
return this.groups.filter(g => {
|
||||
if (classId === null) {
|
||||
return g.classId === null || g.classId === undefined;
|
||||
}
|
||||
return g.classId === classId;
|
||||
});
|
||||
},
|
||||
isClassDoubles(classId) {
|
||||
if (classId === null || classId === '__none__' || classId === 'null' || classId === undefined) {
|
||||
return false;
|
||||
}
|
||||
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
|
||||
const classItem = this.tournamentClasses.find(c => c.id === classIdNum);
|
||||
return classItem ? Boolean(classItem.isDoubles) : false;
|
||||
},
|
||||
labelGender(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return 'Männlich';
|
||||
if (v === 'female') return 'Weiblich';
|
||||
if (v === 'diverse') return 'Divers';
|
||||
return 'Unbekannt';
|
||||
},
|
||||
genderSymbol(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return '♂';
|
||||
if (v === 'female') return '♀';
|
||||
if (v === 'diverse') return '⚧';
|
||||
return '?';
|
||||
},
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.participants-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
flex: 0 0 350px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.participants-table-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.participants-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.participants-table-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
413
frontend/src/components/tournament/TournamentResultsTab.vue
Normal file
413
frontend/src/components/tournament/TournamentResultsTab.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<TournamentClassSelector
|
||||
v-if="selectedDate && selectedDate !== 'new'"
|
||||
:model-value="selectedViewClass"
|
||||
:tournament-classes="tournamentClasses"
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section v-if="groupMatches.length" class="group-matches">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.round') }}</th>
|
||||
<th>{{ $t('tournaments.group') }}</th>
|
||||
<th>{{ $t('tournaments.encounter') }}</th>
|
||||
<th>{{ $t('tournaments.result') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
<th>{{ $t('tournaments.action') }}</th>
|
||||
</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)">
|
||||
<td>{{ m.groupRound }}</td>
|
||||
<td>
|
||||
<template v-if="getGroupClassName(m.groupId)">
|
||||
{{ getGroupClassName(m.groupId) }} - {{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
<span v-if="winnerIsPlayer1(m)">
|
||||
<strong>{{ getMatchPlayerNames(m).name1 }}</strong> – {{ getMatchPlayerNames(m).name2 }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ getMatchPlayerNames(m).name1 }} – <strong>{{ getMatchPlayerNames(m).name2 }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getMatchPlayerNames(m).name1 }} – {{ getMatchPlayerNames(m).name2 }}
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="!m.isFinished">
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
<input
|
||||
:value="editingResult.value"
|
||||
@input="$emit('update:editingResult', { ...editingResult, value: $event.target.value })"
|
||||
@keyup.enter="$emit('save-edited-result', m)"
|
||||
@blur="$emit('save-edited-result', m)"
|
||||
@keyup.escape="$emit('cancel-edit')"
|
||||
class="inline-input"
|
||||
ref="editInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span @click="$emit('start-edit-result', m, r)" class="result-text clickable">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
</template>
|
||||
</template>
|
||||
<div class="new-set-line">
|
||||
<input
|
||||
v-model="m.resultInput"
|
||||
placeholder="Neuen Satz, z.B. 11:7"
|
||||
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
|
||||
@blur="$emit('save-match-result', m, m.resultInput)"
|
||||
class="inline-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatResult(m) }}
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ getSetsString(m) }}
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Abschließen</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen">⏸️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div v-if="participants.length > 1 && !groupMatches.length && !knockoutMatches.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">
|
||||
<button @click="$emit('start-knockout')">
|
||||
{{ $t('tournaments.startKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showKnockout && canResetKnockout && getTotalNumberOfGroups > 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">
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.class') }}</th>
|
||||
<th>{{ $t('tournaments.round') }}</th>
|
||||
<th>{{ $t('tournaments.encounter') }}</th>
|
||||
<th>{{ $t('tournaments.result') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
<th>{{ $t('tournaments.action') }}</th>
|
||||
</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)">
|
||||
<td>{{ getKnockoutMatchClassName(m) }}</td>
|
||||
<td>{{ m.round }}</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
<span v-if="winnerIsPlayer1(m)">
|
||||
<strong>{{ getMatchPlayerNames(m).name1 }}</strong> – {{ getMatchPlayerNames(m).name2 }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ getMatchPlayerNames(m).name1 }} – <strong>{{ getMatchPlayerNames(m).name2 }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getMatchPlayerNames(m).name1 }} – {{ getMatchPlayerNames(m).name2 }}
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="!m.isFinished">
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
<input
|
||||
:value="editingResult.value"
|
||||
@input="$emit('update:editingResult', { ...editingResult, value: $event.target.value })"
|
||||
@keyup.enter="$emit('save-edited-result', m)"
|
||||
@blur="$emit('save-edited-result', m)"
|
||||
@keyup.escape="$emit('cancel-edit')"
|
||||
class="inline-input"
|
||||
ref="editInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span @click="$emit('start-edit-result', m, r)" class="result-text clickable">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
</template>
|
||||
</template>
|
||||
<div class="new-set-line">
|
||||
<input
|
||||
v-model="m.resultInput"
|
||||
placeholder="Neuen Satz, z.B. 11:7"
|
||||
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
|
||||
@blur="$emit('save-match-result', m, m.resultInput)"
|
||||
class="inline-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatResult(m) }}
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ getSetsString(m) }}
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Fertig</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen">⏸️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section v-if="Object.keys(groupedRankingList).length > 0" class="ranking">
|
||||
<h4>Rangliste</h4>
|
||||
<template v-for="(classKey, idx) in Object.keys(groupedRankingList).sort((a, b) => {
|
||||
const aNum = a === 'null' ? 999999 : parseInt(a);
|
||||
const bNum = b === 'null' ? 999999 : parseInt(b);
|
||||
return aNum - bNum;
|
||||
})" :key="`class-${classKey}`">
|
||||
<div v-if="idx > 0" style="margin-top: 2rem;"></div>
|
||||
<h5 v-if="getClassName(classKey)">{{ getClassName(classKey) }}</h5>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platz</th>
|
||||
<th>Spieler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey]" :key="`${entry.member.id}-${entryIdx}`">
|
||||
<td>{{ entry.position }}.</td>
|
||||
<td>
|
||||
{{ entry.member.firstName }}
|
||||
{{ entry.member.lastName }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TournamentClassSelector from './TournamentClassSelector.vue';
|
||||
|
||||
export default {
|
||||
name: 'TournamentResultsTab',
|
||||
components: {
|
||||
TournamentClassSelector
|
||||
},
|
||||
props: {
|
||||
selectedDate: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
selectedViewClass: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
tournamentClasses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
groupMatches: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
knockoutMatches: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
activeMatchId: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
},
|
||||
editingResult: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
canStartKnockout: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
showKnockout: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
canResetKnockout: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
getTotalNumberOfGroups: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
groupedRankingList: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
participants: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
pairings: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:selectedViewClass',
|
||||
'update:activeMatchId',
|
||||
'update:editingResult',
|
||||
'save-edited-result',
|
||||
'cancel-edit',
|
||||
'start-edit-result',
|
||||
'save-match-result',
|
||||
'finish-match',
|
||||
'reopen-match',
|
||||
'set-match-active',
|
||||
'start-matches',
|
||||
'start-knockout',
|
||||
'reset-knockout'
|
||||
],
|
||||
methods: {
|
||||
getGroupClassName(groupId) {
|
||||
if (!groupId) return '';
|
||||
const group = this.groups.find(g => g.groupId === groupId);
|
||||
if (!group || !group.classId) return '';
|
||||
return this.getClassName(group.classId);
|
||||
},
|
||||
getKnockoutMatchClassName(match) {
|
||||
if (!match) return '';
|
||||
if (match.classId) {
|
||||
return this.getClassName(match.classId);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
getClassName(classId) {
|
||||
if (classId === null || classId === '__none__' || classId === 'null' || classId === 'undefined' || classId === undefined) {
|
||||
return this.$t('tournaments.withoutClass');
|
||||
}
|
||||
try {
|
||||
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
|
||||
const classItem = this.tournamentClasses.find(c => c.id === classIdNum);
|
||||
return classItem ? classItem.name : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
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),
|
||||
name2: this.getPlayerName(match.player2)
|
||||
};
|
||||
},
|
||||
getPlayerName(p) {
|
||||
if (p.member) {
|
||||
return p.member.firstName + ' ' + p.member.lastName;
|
||||
} else {
|
||||
return (p.firstName || '') + ' ' + (p.lastName || '');
|
||||
}
|
||||
},
|
||||
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');
|
||||
},
|
||||
formatResult(match) {
|
||||
if (!match.tournamentResults?.length) return '-';
|
||||
return match.tournamentResults
|
||||
.sort((a, b) => a.set - b.set)
|
||||
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
|
||||
.join(', ');
|
||||
},
|
||||
getSetsString(match) {
|
||||
const results = match.tournamentResults || [];
|
||||
let win1 = 0, win2 = 0;
|
||||
for (const r of results) {
|
||||
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
|
||||
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
|
||||
}
|
||||
return `${win1}:${win2}`;
|
||||
},
|
||||
winnerIsPlayer1(match) {
|
||||
const [w1, w2] = this.getSetsString(match).split(':').map(Number);
|
||||
return w1 > w2;
|
||||
},
|
||||
isEditing(match, set) {
|
||||
return (
|
||||
this.editingResult.matchId === match.id &&
|
||||
this.editingResult.set === set
|
||||
);
|
||||
},
|
||||
isLastResult(match, result) {
|
||||
const arr = match.tournamentResults || [];
|
||||
return arr.length > 0 && arr[arr.length - 1].set === result.set;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -578,6 +578,17 @@
|
||||
"delete": "Löschen",
|
||||
"className": "Klassenname",
|
||||
"addClass": "Klasse hinzufügen",
|
||||
"noClassesYet": "Noch keine Klassen vorhanden. Fügen Sie eine neue Klasse hinzu.",
|
||||
"singles": "Einzel",
|
||||
"doubles": "Doppel",
|
||||
"genderAll": "Alle",
|
||||
"genderMixed": "Mixed",
|
||||
"minBirthYear": "Geboren im Jahr oder später",
|
||||
"selectClass": "Klasse auswählen",
|
||||
"tabConfig": "Konfiguration",
|
||||
"tabGroups": "Gruppen",
|
||||
"tabParticipants": "Teilnehmer",
|
||||
"tabResults": "Ergebnisse",
|
||||
"participants": "Teilnehmer",
|
||||
"seeded": "Gesetzt",
|
||||
"club": "Verein",
|
||||
@@ -597,7 +608,13 @@
|
||||
"addClubMember": "Vereinsmitglied hinzufügen",
|
||||
"advancersPerGroup": "Aufsteiger pro Gruppe",
|
||||
"maxGroupSize": "Maximale Gruppengröße",
|
||||
"groupsPerClass": "Gruppen pro Klasse",
|
||||
"groupsPerClass": "Gruppen",
|
||||
"groupsPerClassHint": "Geben Sie für jede Klasse die Anzahl der Gruppen ein (0 = keine Gruppen für diese Klasse):",
|
||||
"showClass": "Klasse anzeigen",
|
||||
"allClasses": "Alle Klassen",
|
||||
"withoutClass": "Ohne Klasse",
|
||||
"currentClass": "Aktive Klasse",
|
||||
"selectClassPrompt": "Bitte wählen Sie oben eine Klasse aus.",
|
||||
"numberOfGroups": "Anzahl Gruppen",
|
||||
"createGroups": "Gruppen erstellen",
|
||||
"randomizeGroups": "Zufällig verteilen",
|
||||
@@ -612,6 +629,13 @@
|
||||
"diff": "Diff",
|
||||
"pointsRatio": "Spielpunkte",
|
||||
"livePosition": "Live-Platz",
|
||||
"pairings": "Doppel-Paarungen",
|
||||
"addPairing": "Paarung hinzufügen",
|
||||
"selectPlayer": "Spieler auswählen",
|
||||
"external": "Extern",
|
||||
"randomPairings": "Zufällige Doppel-Paarungen",
|
||||
"errorMoreSeededThanUnseeded": "Es gibt mehr gesetzte als nicht gesetzte Spieler. Zufällige Paarungen können nicht erstellt werden.",
|
||||
"randomPairingsCreated": "Zufällige Paarungen wurden erstellt.",
|
||||
"resetGroupMatches": "Gruppenspiele",
|
||||
"groupMatches": "Gruppenspiele",
|
||||
"round": "Runde",
|
||||
@@ -1166,6 +1190,7 @@
|
||||
"title": "Trainings-Details",
|
||||
"birthdate": "Geburtsdatum",
|
||||
"birthYear": "Geburtsjahr",
|
||||
"maxBirthYear": "Geboren ≤ Jahr",
|
||||
"last12Months": "Letzte 12 Monate",
|
||||
"last3Months": "Letzte 3 Monate",
|
||||
"total": "Gesamt",
|
||||
|
||||
@@ -17,20 +17,27 @@ export const connectSocket = (clubId) => {
|
||||
// Produktion: HTTPS direkt auf Port 3051
|
||||
socketUrl = 'https://tt-tagebuch.de:3051';
|
||||
} else {
|
||||
// Entwicklung: Verwende backendBaseUrl
|
||||
// Entwicklung: Socket.IO läuft auf demselben Port wie der HTTP-Server (3005)
|
||||
// Oder auf HTTPS-Port 3051, falls SSL-Zertifikate vorhanden sind
|
||||
// Versuche zuerst HTTP, dann HTTPS
|
||||
socketUrl = backendBaseUrl;
|
||||
// Falls der Server auf HTTPS-Port 3051 läuft, verwende diesen
|
||||
// (wird automatisch auf HTTP zurückfallen, wenn HTTPS nicht verfügbar ist)
|
||||
}
|
||||
|
||||
// Bestimme, ob wir HTTPS verwenden
|
||||
const isHttps = socketUrl.startsWith('https://');
|
||||
|
||||
socket = io(socketUrl, {
|
||||
path: '/socket.io/',
|
||||
transports: ['websocket', 'polling'], // WebSocket zuerst, dann Fallback zu Polling
|
||||
transports: ['polling', 'websocket'], // Polling zuerst für bessere Kompatibilität, dann WebSocket
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 20000,
|
||||
upgrade: true,
|
||||
forceNew: false,
|
||||
secure: true, // Wichtig für HTTPS
|
||||
secure: isHttps, // Nur für HTTPS
|
||||
rejectUnauthorized: false // Für selbst-signierte Zertifikate (nur Entwicklung)
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user