feat(TournamentStats): integrate InternalTournamentStats dialog and state management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s

- Added InternalTournamentStats component to App.vue for displaying tournament statistics.
- Implemented state management for the dialog's visibility using Vuex, allowing for better control of the dialog's open state.
- Updated the TournamentsView to utilize the new Vuex mutation for opening the statistics dialog.
- Enhanced the InternalTournamentStats component with a new gender selection dropdown, replacing the previous checkbox implementation for improved user experience.
- Updated localization strings to support new filtering options and terminology related to gender and age classes across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-08 14:26:42 +02:00
parent 003b8fd3bc
commit 757507f212
10 changed files with 104 additions and 57 deletions

View File

@@ -180,6 +180,11 @@
<!-- Dialog Manager -->
<DialogManager />
<InternalTournamentStats
v-if="showInternalTournamentStatsDialog"
v-model="internalTournamentStatsDialogOpen"
/>
<footer class="app-footer">
<div class="footer-content">
@@ -223,14 +228,17 @@ import BaseDialog from './components/BaseDialog.vue';
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
const DialogManager = defineAsyncComponent(() => import('./components/DialogManager.vue'));
import InternalTournamentStats from './components/tournament/InternalTournamentStats.vue';
export default {
name: 'App',
components: {
DialogManager
,
BaseDialog,
InfoDialog,
ConfirmDialog},
DialogManager,
InternalTournamentStats,
BaseDialog,
InfoDialog,
ConfirmDialog,
},
data() {
return {
// Dialog States
@@ -286,7 +294,22 @@ export default {
},
viewReloadKey() {
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;
}
},
internalTournamentStatsDialogOpen: {
get() {
return this.$store.state.internalTournamentStatsOpen;
},
set(v) {
this.$store.commit('setInternalTournamentStatsOpen', v);
},
},
showInternalTournamentStatsDialog() {
return (
this.isAuthenticated &&
!!this.currentClub &&
this.hasPermission('tournaments', 'read')
);
},
},
watch: {
currentClub(newVal) {

View File

@@ -62,17 +62,17 @@
</div>
<div class="age-filter-column">
<div class="age-filter-column-title">{{ $t('tournaments.internalStatsFilterGenderColumn') }}</div>
<div class="age-filter-checkboxes">
<label v-for="g in genderFilterOptions" :key="'g-' + g.mode" class="age-filter-item">
<input
type="checkbox"
:checked="selectedGenders.includes(g.mode)"
:disabled="loading"
@change="onGenderCheckboxChange(g.mode, $event.target.checked)"
/>
<span>{{ g.label }}</span>
</label>
</div>
<label class="gender-select-label">
<select
v-model="genderScope"
class="gender-scope-select"
:disabled="loading"
@change="onGenderScopeChange"
>
<option value="all">{{ $t('tournaments.internalStatsGenderScopeAll') }}</option>
<option value="female">{{ $t('tournaments.internalStatsGenderScopeGirls') }}</option>
</select>
</label>
</div>
</div>
</fieldset>
@@ -156,8 +156,8 @@ export default {
},
/** '9'|'11'|…|'19'|'adult' */
selectedBandKeys: [],
/** 'female'|'open' */
selectedGenders: [],
/** 'all' = weiblich + offen; 'female' = nur Mädchen/Weiblich-Kanal */
genderScope: 'all',
ageFilterInitialized: false,
pendingResetAgeSelection: false,
};
@@ -191,18 +191,15 @@ export default {
}
return [...byKey.values()].sort((a, b) => a.sortKey - b.sortKey);
},
genderFilterOptions() {
return [
{ mode: 'female', label: this.tournamentClassGenderLabelFromMode('female') },
{ mode: 'open', label: this.tournamentClassGenderLabelFromMode('open') },
];
effectiveGenderModes() {
return this.genderScope === 'female' ? ['female'] : ['female', 'open'];
},
effectiveAgeClassKeys() {
const opts = this.stats.ageClassOptions || [];
const valid = new Set(opts.map((o) => o.key));
const keys = [];
for (const bk of this.selectedBandKeys) {
for (const g of this.selectedGenders) {
for (const g of this.effectiveGenderModes) {
const key = bk === 'adult' ? `tt|adult|${g}` : `tt|${bk}|${g}`;
if (valid.has(key)) keys.push(key);
}
@@ -214,7 +211,7 @@ export default {
clubId() {
this.ageFilterInitialized = false;
this.selectedBandKeys = [];
this.selectedGenders = [];
this.genderScope = 'all';
this.pendingResetAgeSelection = false;
if (this.modelValue) this.load();
},
@@ -289,32 +286,19 @@ export default {
}
this.load();
},
onGenderCheckboxChange(mode, checked) {
if (checked) {
if (!this.selectedGenders.includes(mode)) {
this.selectedGenders = [...this.selectedGenders, mode];
}
} else {
this.selectedGenders = this.selectedGenders.filter((m) => m !== mode);
}
onGenderScopeChange() {
this.load();
},
selectAllAgeKeys() {
const opts = this.stats.ageClassOptions || [];
this.selectedBandKeys = this.allBandKeysFromOptions(opts);
this.selectedGenders = ['female', 'open'];
this.genderScope = 'all';
this.load();
},
selectNoAgeKeys() {
this.selectedBandKeys = [];
this.selectedGenders = [];
this.load();
},
/** TT: nur Weiblich vs. Alle (offen) */
tournamentClassGenderLabelFromMode(genderMode) {
if (genderMode === 'female') return this.$t('tournaments.tournamentClassGenderFemale');
return this.$t('tournaments.tournamentClassGenderOpen');
},
formatBandOnly(b) {
if (!b) return '';
if (b.band === 'youth' && b.bandNum != null) return `J${b.bandNum}`;
@@ -335,14 +319,15 @@ export default {
const bandLabels = this.bandOptions
.filter((b) => this.selectedBandKeys.includes(b.bandKey))
.map((b) => this.formatBandOnly(b));
const genderLabels = this.selectedGenders.map((m) => this.tournamentClassGenderLabelFromMode(m));
const genderLabel =
this.genderScope === 'female'
? this.$t('tournaments.internalStatsGenderScopeGirls')
: this.$t('tournaments.internalStatsGenderScopeAll');
const parts = [];
if (bandLabels.length) {
parts.push(`${this.$t('tournaments.internalStatsFilterAgeBands')}: ${bandLabels.join(', ')}`);
}
if (genderLabels.length) {
parts.push(`${this.$t('tournaments.internalStatsFilterGenderColumn')}: ${genderLabels.join(', ')}`);
}
parts.push(`${this.$t('tournaments.internalStatsFilterGenderColumn')}: ${genderLabel}`);
return parts.length ? `${this.$t('tournaments.internalStatsAgeFilter')}: ${parts.join(' · ')}` : '';
},
exportPdf() {
@@ -486,12 +471,12 @@ export default {
const opts = this.stats.ageClassOptions || [];
if (this.pendingResetAgeSelection) {
this.selectedBandKeys = this.allBandKeysFromOptions(opts);
this.selectedGenders = ['female', 'open'];
this.genderScope = 'all';
this.pendingResetAgeSelection = false;
this.ageFilterInitialized = true;
} else if (!this.ageFilterInitialized && opts.length) {
this.selectedBandKeys = this.allBandKeysFromOptions(opts);
this.selectedGenders = ['female', 'open'];
this.genderScope = 'all';
this.ageFilterInitialized = true;
}
} catch (e) {
@@ -608,6 +593,22 @@ export default {
margin-bottom: 0.4rem;
}
.gender-select-label {
display: block;
margin: 0;
}
.gender-scope-select {
width: 100%;
max-width: 14rem;
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid var(--border-color, #d1d5db);
font-size: 0.88rem;
background: #fff;
color: inherit;
}
.age-filter-actions {
display: flex;
gap: 0.75rem;

View File

@@ -179,6 +179,10 @@
"internalStatsOpenButton": "Turnierstatistik (Einzel)",
"internalStatsExportPdf": "Als PDF exportieren",
"internalStatsAgeFilter": "Altersklassä & Gschlächt (Einzel)",
"internalStatsFilterAgeBands": "Altersklassä",
"internalStatsFilterGenderColumn": "Gschlächt",
"internalStatsGenderScopeAll": "Alli",
"internalStatsGenderScopeGirls": "Mädchen",
"tournamentClassGenderFemale": "Weiblich",
"tournamentClassGenderOpen": "Alli",
"internalStatsTtAdult": "Erwachsene",

View File

@@ -410,6 +410,10 @@
"internalStatsOpenButton": "Turnierstatistik (Einzel)",
"internalStatsExportPdf": "Als PDF exportieren",
"internalStatsAgeFilter": "Altersklassen & Geschlecht (Einzel)",
"internalStatsFilterAgeBands": "Altersklassen",
"internalStatsFilterGenderColumn": "Geschlecht",
"internalStatsGenderScopeAll": "Alle",
"internalStatsGenderScopeGirls": "Mädchen",
"tournamentClassGenderFemale": "Weiblich",
"tournamentClassGenderOpen": "Alle",
"internalStatsTtAdult": "Erwachsene",

View File

@@ -704,6 +704,8 @@
"internalStatsAgeFilter": "Altersklassen & Geschlecht (Einzel)",
"internalStatsFilterAgeBands": "Altersklassen",
"internalStatsFilterGenderColumn": "Geschlecht",
"internalStatsGenderScopeAll": "Alle",
"internalStatsGenderScopeGirls": "Mädchen",
"tournamentClassGenderFemale": "Weiblich",
"tournamentClassGenderOpen": "Alle",
"internalStatsTtAdult": "Erwachsene",
@@ -717,7 +719,7 @@
"internalStatsLast6Months": "Letzte 6 Monate",
"internalStatsLast3Months": "Letzte 3 Monate",
"internalStatsTournamentsInPeriod": "{count} Turnier(e) im Zeitraum (ohne Minimeisterschaften).",
"internalStatsPointsExplain": "Wertung: Pro Gruppe wird die Platzierung als Prozentzahl ausgedrückt (bei N Teilnehmern mit Platzierung: 1. = 100 %, Letzter = 0 %, dazwischen linear; gleiche Platzierung = gleicher Wert). N umfasst alle Platzierten in der Gruppe (inkl. Gäste). Bei nur einem Teilnehmer: 100 %. Wer die K.-o.-Runde erreicht, erhält den höchsten Gruppenwert der Klasse plus 1, danach je gewonnenes K.-o.-Spiel einen weiteren Punkt. Nur Vereinsmitglieder (Einzel). Die Filter J9J19 / Erwachsene und Weiblich/Alle beziehen sich auf das jeweilige Mitglied (Geburtsdatum und Geschlecht laut Vereinsdaten), nicht auf die Bezeichnung der Turnierklasse.",
"internalStatsPointsExplain": "Wertung: Pro Gruppe wird die Platzierung als Prozentzahl ausgedrückt (bei N Teilnehmern mit Platzierung: 1. = 100 %, Letzter = 0 %, dazwischen linear; gleiche Platzierung = gleicher Wert). N umfasst alle Platzierten in der Gruppe (inkl. Gäste). Bei nur einem Teilnehmer: 100 %. Wer die K.-o.-Runde erreicht, erhält den höchsten Gruppenwert der Klasse plus 1, danach je gewonnenes K.-o.-Spiel einen weiteren Punkt. Nur Vereinsmitglieder (Einzel). Die Filter J9J19 / Erwachsene und Geschlecht („Alle“ = Weiblich- und Offen-Kanal, „Mädchen“ = nur Weiblich) beziehen sich auf das jeweilige Mitglied (Geburtsdatum und Geschlecht laut Vereinsdaten), nicht auf die Bezeichnung der Turnierklasse.",
"internalStatsAbsoluteRank": "Rangliste Gesamtwertung",
"internalStatsAverageRank": "Rangliste Durchschnitt (pro Turnier)",
"internalStatsPoints": "Summe",

View File

@@ -179,6 +179,10 @@
"internalStatsOpenButton": "Tournament statistics (singles)",
"internalStatsExportPdf": "Export as PDF",
"internalStatsAgeFilter": "Age group & gender (singles)",
"internalStatsFilterAgeBands": "Age classes",
"internalStatsFilterGenderColumn": "Gender",
"internalStatsGenderScopeAll": "All",
"internalStatsGenderScopeGirls": "Girls only",
"tournamentClassGenderFemale": "Female",
"tournamentClassGenderOpen": "Open (all)",
"internalStatsTtAdult": "Adults",

View File

@@ -362,6 +362,8 @@
"internalStatsAgeFilter": "Age group & gender (singles)",
"internalStatsFilterAgeBands": "Age classes",
"internalStatsFilterGenderColumn": "Gender",
"internalStatsGenderScopeAll": "All",
"internalStatsGenderScopeGirls": "Girls only",
"tournamentClassGenderFemale": "Female",
"tournamentClassGenderOpen": "Open (all)",
"internalStatsTtAdult": "Adults",
@@ -375,7 +377,7 @@
"internalStatsLast6Months": "Last 6 months",
"internalStatsLast3Months": "Last 3 months",
"internalStatsTournamentsInPeriod": "{count} tournament(s) in this period (excluding mini championships).",
"internalStatsPointsExplain": "Scoring: In each group, placement is expressed as a percentage (with N ranked players: 1st = 100%, last = 0%, linear in between; tied ranks share the same value). N counts everyone ranked in that group (including guests). With only one player: 100%. Players who reach the knockout get the highest group score in that class plus 1, then one extra point per knockout match won. Club members in singles classes only. The J9J19 / adults and female/open filters use each members birth date and gender from club records, not the tournament class name.",
"internalStatsPointsExplain": "Scoring: In each group, placement is expressed as a percentage (with N ranked players: 1st = 100%, last = 0%, linear in between; tied ranks share the same value). N counts everyone ranked in that group (including guests). With only one player: 100%. Players who reach the knockout get the highest group score in that class plus 1, then one extra point per knockout match won. Club members in singles classes only. Age bands (J9J19 / adults) and gender (“All” = female and open channels, “Girls only” = female channel) use each members birth date and gender from club records, not the tournament class name.",
"internalStatsAbsoluteRank": "Total score ranking",
"internalStatsAverageRank": "Average per tournament",
"internalStatsPoints": "Total",

View File

@@ -179,6 +179,10 @@
"internalStatsOpenButton": "Tournament statistics (singles)",
"internalStatsExportPdf": "Export as PDF",
"internalStatsAgeFilter": "Age group & gender (singles)",
"internalStatsFilterAgeBands": "Age classes",
"internalStatsFilterGenderColumn": "Gender",
"internalStatsGenderScopeAll": "All",
"internalStatsGenderScopeGirls": "Girls only",
"tournamentClassGenderFemale": "Female",
"tournamentClassGenderOpen": "Open (all)",
"internalStatsTtAdult": "Adults",

View File

@@ -43,6 +43,8 @@ const store = createStore({
// Browser-Sprache wird in i18n/index.js erkannt
return null;
})(),
/** Turnierstatistik-Einzel: Dialog bleibt beim Seitenwechsel offen (App.vue) */
internalTournamentStatsOpen: false,
},
mutations: {
setToken(state, token) {
@@ -72,6 +74,7 @@ const store = createStore({
safeSessionStorage.setItem('currentClub', club);
} else {
safeSessionStorage.removeItem('currentClub');
state.internalTournamentStatsOpen = false;
}
},
setClubsMutation(state, clubs) {
@@ -100,7 +103,8 @@ const store = createStore({
clearToken(state) {
state.token = null;
safeSessionStorage.removeItem('token');
safeSessionStorage.removeItem('currentClub');
safeSessionStorage.removeItem('currentClub');
state.internalTournamentStatsOpen = false;
},
clearUsername(state) {
state.username = '';
@@ -142,7 +146,10 @@ const store = createStore({
const maxZIndex = Math.max(...state.dialogs.map(d => d.zIndex));
dialog.zIndex = maxZIndex + 1;
}
}
},
setInternalTournamentStatsOpen(state, open) {
state.internalTournamentStatsOpen = !!open;
},
},
actions: {
async login({ commit }, { token, username }) {
@@ -153,6 +160,7 @@ const store = createStore({
commit('setClubsMutation', response.data);
},
logout({ commit }) {
commit('setInternalTournamentStatsOpen', false);
commit('clearToken');
commit('clearUsername');
commit('clearPermissions');

View File

@@ -34,14 +34,12 @@
v-if="activeMode === 'internal'"
type="button"
class="stats-open-button"
@click="internalStatsOpen = true"
@click="$store.commit('setInternalTournamentStatsOpen', true)"
>
📊 {{ $t('tournaments.internalStatsOpenButton') }}
</button>
</div>
<InternalTournamentStats v-if="activeMode === 'internal'" v-model="internalStatsOpen" />
<div class="tab-content">
<TournamentTab
:key="activeMode"
@@ -54,18 +52,15 @@
<script>
import TournamentTab from './TournamentTab.vue';
import InternalTournamentStats from '../components/tournament/InternalTournamentStats.vue';
export default {
name: 'TournamentsView',
components: {
TournamentTab,
InternalTournamentStats,
},
data() {
return {
activeMode: 'internal',
internalStatsOpen: false,
};
},
computed: {