feat(MembersOverview): add season filter and enhance age group selection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s

- Introduced a new season filter dropdown in the MembersOverviewSection for selecting the season start year.
- Enhanced age group selection by organizing options into groups for better clarity and added new age categories.
- Updated localization files to include new terms related to the season filter and age classifications across multiple languages.
- Improved the overall layout and styling of the filter components for a better user experience.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 15:26:08 +02:00
parent b62b61505c
commit 8b9a4b7bca
18 changed files with 500 additions and 101 deletions

View File

@@ -0,0 +1,10 @@
-- Jugend-Freigaben (Schema wie backend/models/Member.js)
-- Fehlende Spalten verursachen SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubMembers.
ALTER TABLE `member`
ADD COLUMN `adult_release_approved` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Jugendspieler mit Freigabe fuer Erwachsene'
AFTER `member_form_handed_over`,
ADD COLUMN `adult_reserve_approved` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Jugendspieler als Ersatz bei Erwachsenen zugelassen'
AFTER `adult_release_approved`;

View File

@@ -59,16 +59,38 @@
<span>{{ $t('members.showInactiveMembers') }}</span>
</label>
</div>
<div class="filter-group">
<label>{{ $t('members.ttSeasonFilter') }}</label>
<select
:value="selectedSeasonStartYear"
class="filter-select"
@change="$emit('update:selected-season-start-year', Number($event.target.value))"
>
<option v-for="opt in seasonFilterOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<p class="filter-hint filter-hint-tt">{{ $t('members.ttStichtagHint') }}</p>
</div>
<div class="filter-group">
<label>{{ $t('members.ageGroup') }}:</label>
<select :value="selectedAgeGroup" class="filter-select" @change="$emit('update:selected-age-group', $event.target.value)">
<option value="">{{ $t('common.all') }}</option>
<option value="adult">{{ $t('members.adults') }}</option>
<option value="J19">{{ $t('members.j19') }}</option>
<option value="J17">{{ $t('members.j17') }}</option>
<option value="J15">{{ $t('members.j15') }}</option>
<option value="J13">{{ $t('members.j13') }}</option>
<option value="J11">{{ $t('members.j11') }}</option>
<option value="adult">{{ $t('members.ttAdult') }}</option>
<optgroup :label="$t('members.ttFilterGroupJ')">
<option value="J19">{{ $t('members.j19') }}</option>
<option value="J15">{{ $t('members.j15') }}</option>
<option value="J13">{{ $t('members.j13') }}</option>
<option value="J11">{{ $t('members.j11') }}</option>
<option value="J9">{{ $t('members.j9') }}</option>
</optgroup>
<optgroup :label="$t('members.ttFilterGroupM')">
<option value="M19">{{ $t('members.m19') }}</option>
<option value="M15">{{ $t('members.m15') }}</option>
<option value="M13">{{ $t('members.m13') }}</option>
<option value="M11">{{ $t('members.m11') }}</option>
<option value="M9">{{ $t('members.m9') }}</option>
</optgroup>
<option value="range">{{ $t('members.ageRange') }}</option>
</select>
</div>
@@ -199,6 +221,8 @@ export default {
selectedMemberScope: { type: String, required: true },
showInactiveMembers: { type: Boolean, required: true },
selectedAgeGroup: { type: String, required: true },
selectedSeasonStartYear: { type: Number, required: true },
seasonFilterOptions: { type: Array, required: true },
selectedAgeFrom: { type: [String, Number], required: true },
selectedAgeTo: { type: [String, Number], required: true },
selectedGender: { type: String, required: true },
@@ -221,6 +245,7 @@ export default {
'update:selected-member-scope',
'update:show-inactive-members',
'update:selected-age-group',
'update:selected-season-start-year',
'update:selected-age-from',
'update:selected-age-to',
'update:selected-gender',
@@ -297,6 +322,14 @@ export default {
gap: 0.35rem;
}
.filter-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
max-width: 24rem;
line-height: 1.35;
}
.member-search-group {
flex: 1 1 18rem;
}

View File

@@ -467,12 +467,26 @@
"clearFields": "Felder leeren",
"showInactiveMembers": "Inaktive Mitglieder anzeigen",
"ageGroup": "Altersklasse",
"ttSeasonFilter": "Saison (Stichtag)",
"ttSeasonCurrentTag": "aktuell",
"ttSeasonNextTag": "kommend",
"ttStichtagHint": "Stichtag 1.1. (DTTB). Jungen: nur J-Klassen. Mädchen: J und M möglich.",
"ttAgeClassCol": "AK (TT)",
"ttAdult": "Erwachsene (kein Jugend nach Stichtag)",
"ttFilterGroupJ": "Mädchen & Jungen (gemischt)",
"ttFilterGroupM": "Mädchen (nur weiblich)",
"adults": "Erwachsene (18+)",
"j19": "J19 (19 und jünger)",
"j19": "J19",
"j17": "J17 (17 und jünger)",
"j15": "J15 (15 und jünger)",
"j13": "J13 (13 und jünger)",
"j11": "J11 (11 und jünger)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Filter zurücksetzen",
"imageInternet": "Bild (Inet?)",
"testMember": "Testm.",

View File

@@ -242,12 +242,26 @@
"clearFields": "Felder leeren",
"showInactiveMembers": "Inaktive Mitglieder anzeigen",
"ageGroup": "Altersklasse",
"ttSeasonFilter": "Saison (Stichtag)",
"ttSeasonCurrentTag": "aktuell",
"ttSeasonNextTag": "kommend",
"ttStichtagHint": "Stichtag 1.1. (DTTB). Jungen: nur J-Klassen. Mädchen: J und M möglich.",
"ttAgeClassCol": "AK (TT)",
"ttAdult": "Erwachsene (kein Jugend nach Stichtag)",
"ttFilterGroupJ": "Mädchen & Jungen (gemischt)",
"ttFilterGroupM": "Mädchen (nur weiblich)",
"adults": "Erwachsene (18+)",
"j19": "J19 (19 und jünger)",
"j19": "J19",
"j17": "J17 (17 und jünger)",
"j15": "J15 (15 und jünger)",
"j13": "J13 (13 und jünger)",
"j11": "J11 (11 und jünger)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Filter zurücksetzen",
"imageInternet": "Bild (Inet?)",
"testMember": "Testm.",

View File

@@ -467,12 +467,26 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"ttSeasonFilter": "Season (cutoff)",
"ttSeasonCurrentTag": "current",
"ttSeasonNextTag": "upcoming",
"ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.",
"ttAgeClassCol": "Age class (TT)",
"ttAdult": "Adults (not youth by cutoff)",
"ttFilterGroupJ": "Boys & girls (mixed)",
"ttFilterGroupM": "Girls only",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j19": "U19",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
"j13": "U13 (13 and younger)",
"j11": "U11 (11 and younger)",
"j15": "U15",
"j13": "U13",
"j11": "U11",
"j9": "U9",
"m19": "G19",
"m15": "G15",
"m13": "G13",
"m11": "G11",
"m9": "G9",
"clearFilters": "Reset filters",
"imageInternet": "Image (web?)",
"testMember": "Trial",

View File

@@ -742,12 +742,26 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"ttSeasonFilter": "Season (cutoff)",
"ttSeasonCurrentTag": "current",
"ttSeasonNextTag": "upcoming",
"ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.",
"ttAgeClassCol": "Age class (TT)",
"ttAdult": "Adults (not youth by cutoff)",
"ttFilterGroupJ": "Boys & girls (mixed)",
"ttFilterGroupM": "Girls only",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j19": "U19",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
"j13": "U13 (13 and younger)",
"j11": "U11 (11 and younger)",
"j15": "U15",
"j13": "U13",
"j11": "U11",
"j9": "U9",
"m19": "G19",
"m15": "G15",
"m13": "G13",
"m11": "G11",
"m9": "G9",
"clearFilters": "Reset filters",
"imageInternet": "Image (web?)",
"testMember": "Trial",

View File

@@ -467,12 +467,26 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"ttSeasonFilter": "Season (cutoff)",
"ttSeasonCurrentTag": "current",
"ttSeasonNextTag": "upcoming",
"ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.",
"ttAgeClassCol": "Age class (TT)",
"ttAdult": "Adults (not youth by cutoff)",
"ttFilterGroupJ": "Boys & girls (mixed)",
"ttFilterGroupM": "Girls only",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j19": "U19",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
"j13": "U13 (13 and younger)",
"j11": "U11 (11 and younger)",
"j15": "U15",
"j13": "U13",
"j11": "U11",
"j9": "U9",
"m19": "G19",
"m15": "G15",
"m13": "G13",
"m11": "G11",
"m9": "G9",
"clearFilters": "Reset filters",
"imageInternet": "Image (web?)",
"testMember": "Trial",

View File

@@ -434,12 +434,26 @@
"clearFields": "Vaciar campos",
"showInactiveMembers": "Mostrar miembros inactivos",
"ageGroup": "Categoría de edad",
"ttSeasonFilter": "Temporada (corte)",
"ttSeasonCurrentTag": "actual",
"ttSeasonNextTag": "próxima",
"ttStichtagHint": "Corte el 1 de enero (DTTB). Niños: solo clases J. Niñas: J y M.",
"ttAgeClassCol": "Cat. edad (TT)",
"ttAdult": "Adultos (no juvenil según corte)",
"ttFilterGroupJ": "Niños y niñas (mixto)",
"ttFilterGroupM": "Solo niñas",
"adults": "Adultos (20+)",
"j19": "U19 (19 años o menos)",
"j19": "J19",
"j17": "U17 (17 años o menos)",
"j15": "U15 (15 años o menos)",
"j13": "U13 (13 años o menos)",
"j11": "U11 (11 años o menos)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Restablecer filtros",
"imageInternet": "Imagen (web?)",
"testMember": "Prueba",

View File

@@ -434,12 +434,26 @@
"clearFields": "Burahin ang mga field",
"showInactiveMembers": "Ipakita ang mga hindi aktibong miyembro",
"ageGroup": "Pangkat ng edad",
"ttSeasonFilter": "Season (cutoff)",
"ttSeasonCurrentTag": "kasalukuyan",
"ttSeasonNextTag": "susunod",
"ttStichtagHint": "Cutoff Enero 1 (DTTB). Lalaki: J lang. Babae: J at M.",
"ttAgeClassCol": "Edad (TT)",
"ttAdult": "Adulto (hindi youth sa cutoff)",
"ttFilterGroupJ": "Babae at lalaki (halo)",
"ttFilterGroupM": "Babae lamang",
"adults": "Adulto (20+)",
"j19": "J19 (19 pababa)",
"j19": "J19",
"j17": "J17 (17 pababa)",
"j15": "J15 (15 pababa)",
"j13": "J13 (13 pababa)",
"j11": "J11 (11 pababa)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "I-reset ang mga filter",
"imageInternet": "Larawan (net?)",
"testMember": "Trial",

View File

@@ -434,12 +434,26 @@
"clearFields": "Vider les champs",
"showInactiveMembers": "Afficher les membres inactifs",
"ageGroup": "Catégorie d'âge",
"ttSeasonFilter": "Saison (date limite)",
"ttSeasonCurrentTag": "actuelle",
"ttSeasonNextTag": "à venir",
"ttStichtagHint": "Date limite le 1er janv. (DTTB). Garçons : classes J uniquement. Filles : J et M.",
"ttAgeClassCol": "Classe d'âge (TT)",
"ttAdult": "Adultes (pas jeunes selon la date limite)",
"ttFilterGroupJ": "Filles et garçons (mixte)",
"ttFilterGroupM": "Filles uniquement",
"adults": "Adultes (20+)",
"j19": "U19 (19 ans et moins)",
"j19": "J19",
"j17": "U17 (17 ans et moins)",
"j15": "U15 (15 ans et moins)",
"j13": "U13 (13 ans et moins)",
"j11": "U11 (11 ans et moins)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Réinitialiser les filtres",
"imageInternet": "Image (web ?)",
"testMember": "Essai",

View File

@@ -434,12 +434,26 @@
"clearFields": "Svuota campi",
"showInactiveMembers": "Mostra membri inattivi",
"ageGroup": "Fascia detà",
"ttSeasonFilter": "Stagione (data di riferimento)",
"ttSeasonCurrentTag": "attuale",
"ttSeasonNextTag": "prossima",
"ttStichtagHint": "Data di riferimento 1° gennaio (DTTB). Ragazzi: solo classi J. Ragazze: J e M.",
"ttAgeClassCol": "Classe (TT)",
"ttAdult": "Adulti (non giovanili secondo la data)",
"ttFilterGroupJ": "Ragazze e ragazzi (misto)",
"ttFilterGroupM": "Solo ragazze",
"adults": "Adulti (20+)",
"j19": "U19 (19 anni o meno)",
"j19": "J19",
"j17": "U17 (17 anni o meno)",
"j15": "U15 (15 anni o meno)",
"j13": "U13 (13 anni o meno)",
"j11": "U11 (11 anni o meno)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Reimposta filtri",
"imageInternet": "Immagine (web?)",
"testMember": "Prova",

View File

@@ -434,12 +434,26 @@
"clearFields": "入力欄をクリア",
"showInactiveMembers": "非アクティブメンバーを表示",
"ageGroup": "年齢区分",
"ttSeasonFilter": "シーズン(基準日)",
"ttSeasonCurrentTag": "今季",
"ttSeasonNextTag": "来季",
"ttStichtagHint": "基準日1月1日DTTB。男子Jのみ。女子JとM。",
"ttAgeClassCol": "年齢TT",
"ttAdult": "一般(基準日でジュニアではない)",
"ttFilterGroupJ": "男女混合",
"ttFilterGroupM": "女子のみ",
"adults": "一般20歳以上",
"j19": "J1919歳以下",
"j19": "J19",
"j17": "J1717歳以下",
"j15": "J1515歳以下",
"j13": "J1313歳以下",
"j11": "J1111歳以下",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "フィルターをリセット",
"imageInternet": "画像(公開)",
"testMember": "体験",

View File

@@ -434,12 +434,26 @@
"clearFields": "Wyczyść pola",
"showInactiveMembers": "Pokaż nieaktywnych członków",
"ageGroup": "Kategoria wiekowa",
"ttSeasonFilter": "Sezon (termin)",
"ttSeasonCurrentTag": "bieżący",
"ttSeasonNextTag": "następny",
"ttStichtagHint": "Termin 1.01 (DTTB). Chłopcy: tylko klasy J. Dziewczęta: J i M.",
"ttAgeClassCol": "Kategoria (TT)",
"ttAdult": "Dorośli (brak juniora wg terminu)",
"ttFilterGroupJ": "Dziewczęta i chłopcy (mieszane)",
"ttFilterGroupM": "Tylko dziewczęta",
"adults": "Dorośli (20+)",
"j19": "U19 (19 lat i mniej)",
"j19": "J19",
"j17": "U17 (17 lat i mniej)",
"j15": "U15 (15 lat i mniej)",
"j13": "U13 (13 lat i mniej)",
"j11": "U11 (11 lat i mniej)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "Resetuj filtry",
"imageInternet": "Zdjęcie (www?)",
"testMember": "Próba",

View File

@@ -434,12 +434,26 @@
"clearFields": "ล้างช่องข้อมูล",
"showInactiveMembers": "แสดงสมาชิกที่ไม่ใช้งาน",
"ageGroup": "กลุ่มอายุ",
"ttSeasonFilter": "ฤดูกาล (วันตัดสิทธิ์)",
"ttSeasonCurrentTag": "ปัจจุบัน",
"ttSeasonNextTag": "ถัดไป",
"ttStichtagHint": "วันตัดสิทธิ์ 1 ม.ค. (DTTB) ชาย: เฉพาะ J หญิง: J และ M",
"ttAgeClassCol": "รุ่นอายุ (TT)",
"ttAdult": "ผู้ใหญ่ (ไม่ใช่เยาวชนตามวันตัด)",
"ttFilterGroupJ": "ชายและหญิง (รวม)",
"ttFilterGroupM": "เฉพาะหญิง",
"adults": "ผู้ใหญ่ (20+)",
"j19": "J19 (19 ปีหรือน้อยกว่า)",
"j19": "J19",
"j17": "J17 (17 ปีหรือน้อยกว่า)",
"j15": "J15 (15 ปีหรือน้อยกว่า)",
"j13": "J13 (13 ปีหรือน้อยกว่า)",
"j11": "J11 (11 ปีหรือน้อยกว่า)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "รีเซ็ตตัวกรอง",
"imageInternet": "รูปภาพ (เน็ต)",
"testMember": "ทดลอง",

View File

@@ -434,12 +434,26 @@
"clearFields": "Burahin ang mga field",
"showInactiveMembers": "Ipakita ang mga hindi aktibong miyembro",
"ageGroup": "Pangkat ng edad",
"ttSeasonFilter": "Season (cutoff)",
"ttSeasonCurrentTag": "kasalukuyan",
"ttSeasonNextTag": "susunod",
"ttStichtagHint": "Cutoff Enero 1 (DTTB). Lalaki: J lang. Babae: J at M.",
"ttAgeClassCol": "Edad (TT)",
"ttAdult": "Adulto (hindi youth sa cutoff)",
"ttFilterGroupJ": "Babae at lalaki (halo)",
"ttFilterGroupM": "Babae lamang",
"adults": "Adulto (20+)",
"j19": "J19 (19 pababa)",
"j19": "J19",
"j17": "J17 (17 pababa)",
"j15": "J15 (15 pababa)",
"j13": "J13 (13 pababa)",
"j11": "J11 (11 pababa)",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "I-reset ang mga filter",
"imageInternet": "Larawan (net?)",
"testMember": "Trial",

View File

@@ -434,12 +434,26 @@
"clearFields": "清空字段",
"showInactiveMembers": "显示非活跃成员",
"ageGroup": "年龄组",
"ttSeasonFilter": "赛季(截止日)",
"ttSeasonCurrentTag": "当前",
"ttSeasonNextTag": "下一",
"ttStichtagHint": "截止日1月1日DTTB。男孩仅 J 组。女孩J 与 M。",
"ttAgeClassCol": "年龄组TT",
"ttAdult": "成人(按截止日非青年)",
"ttFilterGroupJ": "男女孩(混合)",
"ttFilterGroupM": "仅女孩",
"adults": "成人20岁以上",
"j19": "J1919岁及以下",
"j19": "J19",
"j17": "J1717岁及以下",
"j15": "J1515岁及以下",
"j13": "J1313岁及以下",
"j11": "J1111岁及以下",
"j15": "J15",
"j13": "J13",
"j11": "J11",
"j9": "J9",
"m19": "M19",
"m15": "M15",
"m13": "M13",
"m11": "M11",
"m9": "M9",
"clearFilters": "重置筛选",
"imageInternet": "图片(网络)",
"testMember": "试用",

View File

@@ -0,0 +1,128 @@
/**
* Tischtennis-Altersklassen nach Stichtag 01.01. (z. B. DTTB / Mannschafts- und Einzelspielbetrieb).
* Pro Saison verschieben sich die Jahrgänge um ein Jahr; die Logik nutzt das erste Saisonjahr (z. B. 2025 für 2025/26).
*/
const YOUTH_BANDS = [9, 11, 13, 15, 19];
/**
* @param {Date} [d]
* @returns {number} Erstes Kalenderjahr der laufenden Spielzeit (01.08.31.07. üblich: AugDez = Jahr, JanJul = Vorjahr).
*/
export function getSeasonStartYearFromDate(d = new Date()) {
const month = d.getMonth() + 1;
const year = d.getFullYear();
return month >= 8 ? year : year - 1;
}
/**
* Stichtag 01.01. für „Mädchen & Jungen n“: Geburtsdatum >= 01.01.(Saisonbeginn (n 1))
* Beispiel Saison 2025/26: J19 → 01.01.2007, J15 → 01.01.2011, …, J9 → 01.01.2017
* @param {number} seasonStartYear z. B. 2025
* @param {number} classNum 9 | 11 | 13 | 15 | 19
*/
export function getStichtagDate(seasonStartYear, classNum) {
const y = seasonStartYear - (classNum - 1);
return new Date(y, 0, 1);
}
function birthDateToTime(birthDate) {
if (!birthDate) return null;
let d;
if (birthDate instanceof Date && !Number.isNaN(birthDate.getTime())) {
d = birthDate;
} else if (typeof birthDate === 'string') {
const t = birthDate.trim();
if (t.includes('-')) {
const [a, b, c] = t.split(/[T\s]/)[0].split('-').map(Number);
if (Number.isFinite(a) && Number.isFinite(b) && Number.isFinite(c)) {
d = new Date(a, b - 1, c);
}
} else if (t.includes('.')) {
const parts = t.split('.');
if (parts.length >= 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const year = parseInt(parts[2], 10);
if (Number.isFinite(day) && Number.isFinite(month) && Number.isFinite(year)) {
d = new Date(year, month - 1, day);
}
}
}
}
if (!d || Number.isNaN(d.getTime())) return null;
return d.getTime();
}
/**
* Engste Jugend-Altersklasse (exklusive Bänder zwischen den Stichtagen).
* @returns {'J9'|'J11'|'J13'|'J15'|'J19'|'adult'|null} null = kein gültiges Geburtsdatum
*/
export function getExclusiveJugendClass(birthDate, seasonStartYear) {
const t = birthDateToTime(birthDate);
if (t === null) return null;
const c = (k) => getStichtagDate(seasonStartYear, k).getTime();
const c9 = c(9);
const c11 = c(11);
const c13 = c(13);
const c15 = c(15);
const c19 = c(19);
if (t < c19) return 'adult';
if (t >= c9) return 'J9';
if (t >= c11 && t < c9) return 'J11';
if (t >= c13 && t < c11) return 'J13';
if (t >= c15 && t < c13) return 'J15';
if (t >= c19 && t < c15) return 'J19';
return 'adult';
}
/**
* @param {object} member
* @param {string} filterKey '', 'adult', 'range', 'J9'…'J19', 'M9'…'M19'
* @param {number} seasonStartYear
* @returns {boolean}
*/
export function memberMatchesTtAgeClass(member, filterKey, seasonStartYear) {
if (!filterKey || filterKey === 'range') return true;
const jClass = getExclusiveJugendClass(member.birthDate, seasonStartYear);
if (filterKey === 'adult') {
return jClass === 'adult';
}
if (filterKey.startsWith('J')) {
return jClass === filterKey;
}
if (filterKey.startsWith('M')) {
if (member.gender !== 'female') return false;
const want = `J${filterKey.slice(1)}`;
return jClass === want;
}
return true;
}
/**
* Anzeige in der Liste: Jungen nur J…; Mädchen J… und M…
*/
export function formatMemberTtAgeClassLabels(member, seasonStartYear) {
const j = getExclusiveJugendClass(member.birthDate, seasonStartYear);
if (j === null) return { primary: null, secondary: null };
if (j === 'adult') return { primary: 'adult', secondary: null };
if (member.gender === 'female') {
const m = `M${j.slice(1)}`;
return { primary: j, secondary: m };
}
return { primary: j, secondary: null };
}
export function formatSeasonSlash(seasonStartYear) {
const y = seasonStartYear;
return `${y}/${String(y + 1).slice(-2)}`;
}
export { YOUTH_BANDS };

View File

@@ -12,6 +12,8 @@
:selected-member-scope="selectedMemberScope"
:show-inactive-members="showInactiveMembers"
:selected-age-group="selectedAgeGroup"
:selected-season-start-year="selectedSeasonStartYear"
:season-filter-options="ttSeasonFilterOptions"
:selected-age-from="selectedAgeFrom"
:selected-age-to="selectedAgeTo"
:selected-gender="selectedGender"
@@ -32,6 +34,7 @@
@update:selected-member-scope="selectedMemberScope = $event"
@update:show-inactive-members="showInactiveMembers = $event"
@update:selected-age-group="selectedAgeGroup = $event"
@update:selected-season-start-year="selectedSeasonStartYear = $event"
@update:selected-age-from="selectedAgeFrom = $event"
@update:selected-age-to="selectedAgeTo = $event"
@update:selected-gender="selectedGender = $event"
@@ -332,6 +335,7 @@
<th>{{ $t('members.contact') }}</th>
<th>{{ $t('members.birthdate') }}</th>
<th>{{ $t('members.age') }}</th>
<th>{{ $t('members.ttAgeClassCol') }}</th>
<th>{{ $t('members.lastTraining') }}</th>
<th v-if="hasTestMembers">{{ $t('members.trainingParticipations') }}</th>
<th>{{ $t('members.actions') }}</th>
@@ -417,6 +421,7 @@
</td>
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ getAgeLabel(member.birthDate) }}</td>
<td class="tt-age-class-cell">{{ formatMemberTtAgeClassCell(member) }}</td>
<td>{{ getOptionalFormattedDate(member.lastTraining, 'members.previewNoLastTraining') }}</td>
<td v-if="hasTestMembers">
<span v-if="member.testMembership">{{ member.trainingParticipations || 0 }}</span>
@@ -567,6 +572,12 @@
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { getSafeErrorMessage, getSafeMessage } from '../utils/errorMessages.js';
import {
getSeasonStartYearFromDate,
memberMatchesTtAgeClass,
formatMemberTtAgeClassLabels,
formatSeasonSlash
} from '../utils/ttAgeClass.js';
import PDFGenerator from '../components/PDFGenerator.js';
import InfoDialog from '../components/InfoDialog.vue';
@@ -625,6 +636,20 @@ export default {
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, 'de-DE'));
},
ttSeasonFilterOptions() {
const y0 = getSeasonStartYearFromDate();
return [
{
value: y0,
label: `${formatSeasonSlash(y0)} (${this.$t('members.ttSeasonCurrentTag')})`
},
{
value: y0 + 1,
label: `${formatSeasonSlash(y0 + 1)} (${this.$t('members.ttSeasonNextTag')})`
}
];
},
filteredMembers() {
return this.members.filter(member => {
@@ -664,10 +689,9 @@ export default {
return false;
}
// Altersklasse Filter
// Altersklasse (TT, Stichtag 1.1.)
if (this.selectedAgeGroup && this.selectedAgeGroup !== 'range') {
const age = this.getAge(member.birthDate);
if (!this.matchesAgeGroup(age, this.selectedAgeGroup)) {
if (!memberMatchesTtAgeClass(member, this.selectedAgeGroup, this.selectedSeasonStartYear)) {
return false;
}
}
@@ -940,6 +964,7 @@ export default {
selectedGroupToAdd: '',
showTransferDialog: false,
selectedAgeGroup: '',
selectedSeasonStartYear: getSeasonStartYearFromDate(),
selectedAgeFrom: '',
selectedAgeTo: '',
selectedGender: '',
@@ -994,19 +1019,32 @@ export default {
},
async init() {
try {
await this.loadMembers();
} catch (error) {
console.error(this.$t('members.errorLoadingMembers'), error);
this.membersLoadError = getSafeErrorMessage(error, this.$t('members.errorLoadingMembers'));
}
await this.loadMembers();
},
async loadMembers() {
this.isLoadingMembers = true;
this.membersLoadError = '';
try {
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
this.members = response.data
const payload = response.data;
const ok =
response.status >= 200 &&
response.status < 300 &&
Array.isArray(payload);
if (!ok) {
const raw =
payload &&
typeof payload === 'object' &&
!Array.isArray(payload) &&
(payload.error ?? payload.message);
this.membersLoadError = getSafeMessage(
typeof raw === 'string' ? raw : null,
this.$t('members.errorLoadingMembers')
);
this.members = [];
return;
}
this.members = payload
.map(member => this.applyBackendBaseUrlToMember(member))
.sort((a, b) => {
const lastNameA = a.lastName ? a.lastName.toLowerCase() : '';
@@ -1031,6 +1069,10 @@ export default {
this.selectedPreviewTrainingGroups = [];
}
}
} catch (error) {
console.error(this.$t('members.errorLoadingMembers'), error);
this.membersLoadError = getSafeErrorMessage(error, this.$t('members.errorLoadingMembers'));
this.members = [];
} finally {
this.isLoadingMembers = false;
}
@@ -1181,6 +1223,7 @@ export default {
this.$t('members.status'),
this.$t('members.birthdate'),
this.$t('members.age'),
this.$t('members.ttAgeClassCol'),
this.$t('members.contact'),
this.$t('members.emailAddress')
];
@@ -1190,6 +1233,7 @@ export default {
this.getMemberStatusBadges(member).map(badge => badge.label).join(' | '),
this.getFormattedBirthdate(member.birthDate),
this.getAgeLabel(member.birthDate),
this.formatMemberTtAgeClassCell(member),
this.getFormattedPhoneNumbers(member),
this.getFormattedEmails(member)
]);
@@ -2362,40 +2406,23 @@ export default {
: age;
},
matchesAgeGroup(age, ageGroup) {
if (age === null) return false;
// Tischtennis-Logik: Altersklassen basieren auf Jahrgängen mit Stichtag 1. Januar
// J19 = 19 Jahre und jünger am 1. Januar des aktuellen Jahres
// J17 = 17 Jahre und jünger am 1. Januar des aktuellen Jahres
// etc.
switch (ageGroup) {
case 'adult':
return age > 19; // Erwachsene = älter als 19
case 'J19':
return age <= 19; // J19 = 19 und jünger
case 'J17':
return age <= 17; // J17 = 17 und jünger
case 'J15':
return age <= 15; // J15 = 15 und jünger
case 'J13':
return age <= 13; // J13 = 13 und jünger
case 'J11':
return age <= 11; // J11 = 11 und jünger
default:
return true;
formatMemberTtAgeClassCell(member) {
const { primary, secondary } = formatMemberTtAgeClassLabels(member, this.selectedSeasonStartYear);
if (primary === null) {
return '';
}
if (primary === 'adult') {
return this.$t('members.ttAdult');
}
if (secondary) {
return `${primary} · ${secondary}`;
}
return primary;
},
clearFilters() {
this.selectedAgeGroup = '';
this.selectedSeasonStartYear = getSeasonStartYearFromDate();
this.selectedAgeFrom = '';
this.selectedAgeTo = '';
this.selectedGender = '';
@@ -3319,6 +3346,11 @@ table td {
padding: 0.5rem 1rem 1rem;
}
.tt-age-class-cell {
font-size: 0.88rem;
white-space: nowrap;
}
.action-icons-row {
display: flex;
gap: 0.75rem;