feat(ClubTeam): enhance club team management with lineup features and member eligibility

- Added teamGender and teamAgeGroup fields to ClubTeam model for better categorization.
- Updated create and update club team endpoints to handle new fields and default values.
- Implemented getClubTeamLineup and updateClubTeamLineup functions for managing team lineups.
- Enhanced member management with adultReleaseApproved and adultReserveApproved fields in Member model.
- Updated frontend views to support new lineup features and member eligibility flags.
- Improved localization for new terms related to team management and member eligibility across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-03-31 13:44:28 +02:00
parent cb7830571b
commit 5eff1d63aa
28 changed files with 1325 additions and 72 deletions

View File

@@ -4,6 +4,7 @@
"title": "Trainingstagebuch"
},
"common": {
"period": "Zeitraum",
"loading": "Lade...",
"save": "Speichere",
"saved": "Gespeichert",
@@ -28,6 +29,7 @@
"time": "Zyt",
"new": "Neu",
"update": "Aktualisiere",
"move": "Verschiebe",
"refresh": "Neu lade",
"create": "Erstelle",
"remove": "Entferne",
@@ -449,6 +451,8 @@
"picsInInternetAllowed": "Pics in Internet erlaubt",
"testMembership": "Testmitgliedschaft",
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
"adultReleaseApproved": "Freigabe Erwachsene",
"adultReserveApproved": "Ersatz bei Erwachsenen",
"trainingGroups": "Trainingsgruppen",
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
@@ -463,7 +467,7 @@
"clearFields": "Felder leeren",
"showInactiveMembers": "Inaktive Mitglieder anzeigen",
"ageGroup": "Altersklasse",
"adults": "Erwachsene (20+)",
"adults": "Erwachsene (18+)",
"j19": "J19 (19 und jünger)",
"j17": "J17 (17 und jünger)",
"j15": "J15 (15 und jünger)",
@@ -868,5 +872,22 @@
"targetMiddleShort": "Mitte kurz",
"targetBackhandShort": "Rückhand kurz",
"toTarget": "nach"
},
"teamManagement": {
"lineupProposal": "Mannschaftsmeldung nach QTTR",
"eligibility": "Einsatz",
"eligibilityRegular": "Regulär",
"eligibilityAdultRelease": "Freigabe Erwachsene",
"eligibilityAdultReserve": "Ersatz Erwachsene",
"eligibilityAdultReleaseAndReserve": "Freigabe + Ersatz Erwachsene",
"selectedLineup": "Gemeldete Spieler",
"availableLineupMembers": "Verfügbare Spieler",
"lineupEmpty": "Noch keine Spieler für diese Mannschaft gemeldet.",
"lineupSaveError": "Mannschaftsmeldung konnte nicht gespeichert werden.",
"lineupValidationTooLargeGap": "{higher} hat mehr als 30 QTTR Punkte Vorsprung vor {lower}. Diese Reihenfolge bitte korrigieren.",
"firstHalf": "Vorrunde",
"firstHalfFull": "Vorrunde (Juli - Dezember)",
"secondHalf": "Rückrunde",
"secondHalfFull": "Rückrunde (ab 1. Januar)"
}
}

View File

@@ -53,7 +53,8 @@
"days": "Tage",
"weeks": "Wochen",
"months": "Monate",
"years": "Jahre"
"years": "Jahre",
"period": "Zeitraum"
},
"navigation": {
"home": "Startseite",
@@ -203,6 +204,8 @@
"picsInInternetAllowed": "Pics in Internet erlaubt",
"testMembership": "Testmitgliedschaft",
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
"adultReleaseApproved": "Freigabe Erwachsene",
"adultReserveApproved": "Ersatz bei Erwachsenen",
"trainingGroups": "Trainingsgruppen",
"subtitle": "Mitglieder suchen, filtern und direkt bearbeiten.",
"closeEditor": "Editor schließen",
@@ -574,5 +577,22 @@
"cookies": "Cookies/Local Storage",
"logData": "Logdaten",
"recipients": "Empfänger"
},
"teamManagement": {
"lineupProposal": "Mannschaftsmeldung nach QTTR",
"eligibility": "Einsatz",
"eligibilityRegular": "Regulär",
"eligibilityAdultRelease": "Freigabe Erwachsene",
"eligibilityAdultReserve": "Ersatz Erwachsene",
"eligibilityAdultReleaseAndReserve": "Freigabe + Ersatz Erwachsene",
"selectedLineup": "Gemeldete Spieler",
"availableLineupMembers": "Verfügbare Spieler",
"lineupEmpty": "Noch keine Spieler für diese Mannschaft gemeldet.",
"lineupSaveError": "Mannschaftsmeldung konnte nicht gespeichert werden.",
"lineupValidationTooLargeGap": "{higher} hat mehr als 30 QTTR Punkte Vorsprung vor {lower}. Diese Reihenfolge bitte korrigieren.",
"firstHalf": "Vorrunde",
"firstHalfFull": "Vorrunde (Juli - Dezember)",
"secondHalf": "Rückrunde",
"secondHalfFull": "Rückrunde (ab 1. Januar)"
}
}

View File

@@ -28,6 +28,7 @@
"time": "Zeit",
"new": "Neu",
"update": "Aktualisieren",
"move": "Verschieben",
"refresh": "Neu laden",
"create": "Erstellen",
"remove": "Entfernen",
@@ -57,7 +58,8 @@
"weeks": "Wochen",
"months": "Monate",
"years": "Jahre",
"ok": "OK"
"ok": "OK",
"period": "Zeitraum"
},
"navigation": {
"home": "Startseite",
@@ -224,6 +226,8 @@
"picsInInternetAllowed": "Pics in Internet erlaubt",
"testMembership": "Testmitgliedschaft",
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
"adultReleaseApproved": "Freigabe Erwachsene",
"adultReserveApproved": "Ersatz bei Erwachsenen",
"trainingGroups": "Trainingsgruppen",
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
@@ -238,7 +242,7 @@
"clearFields": "Felder leeren",
"showInactiveMembers": "Inaktive Mitglieder anzeigen",
"ageGroup": "Altersklasse",
"adults": "Erwachsene (20+)",
"adults": "Erwachsene (18+)",
"j19": "J19 (19 und jünger)",
"j17": "J17 (17 und jünger)",
"j15": "J15 (15 und jünger)",
@@ -1323,6 +1327,22 @@
"playerStats": "Spieleinsätze",
"playerStatsIntro": "Schneller Überblick über Einsätze der Mannschaft in dieser Saison.",
"lineupProposal": "Mannschaftsmeldung nach QTTR",
"teamGender": "Geschlecht",
"teamAgeGroup": "Altersklasse",
"teamGenderOpen": "Offen",
"teamGenderFemale": "Weiblich",
"eligibility": "Einsatz",
"eligibilityRegular": "Regulär",
"eligibilityAdultRelease": "Freigabe Erwachsene",
"eligibilityAdultReserve": "Ersatz Erwachsene",
"eligibilityAdultReleaseAndReserve": "Freigabe + Ersatz Erwachsene",
"selectedLineup": "Gemeldete Spieler",
"availableLineupMembers": "Verfügbare Spieler",
"lineupEmpty": "Noch keine Spieler für diese Mannschaft gemeldet.",
"lineupUnsavedChanges": "Ungespeicherte Änderungen in der Mannschaftsmeldung.",
"lineupSaved": "Mannschaftsmeldung gespeichert.",
"lineupSaveError": "Mannschaftsmeldung konnte nicht gespeichert werden.",
"lineupValidationTooLargeGap": "{higher} hat mehr als 30 QTTR Punkte Vorsprung vor {lower}. Diese Reihenfolge bitte korrigieren.",
"refreshStats": "Aktualisieren",
"loadingStats": "Lade Statistiken...",
"noPlayerStats": "Keine Spieleinsätze erfasst.",

View File

@@ -4,6 +4,7 @@
"title": "Training Diary"
},
"common": {
"period": "Period",
"loading": "Loading...",
"save": "Save",
"saved": "Saved",
@@ -29,6 +30,7 @@
"time": "Time",
"new": "New",
"update": "Update",
"move": "Move",
"refresh": "Reload",
"create": "Create",
"remove": "Remove",
@@ -449,6 +451,8 @@
"picsInInternetAllowed": "Pictures allowed online",
"testMembership": "Trial membership",
"memberFormHandedOver": "Membership form handed over",
"adultReleaseApproved": "Approved for adults",
"adultReserveApproved": "Adult reserve",
"trainingGroups": "Training groups",
"noGroupsAssigned": "No groups assigned",
"noGroupsAvailable": "No groups available",
@@ -463,7 +467,7 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"adults": "Adults (20+)",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
@@ -868,5 +872,22 @@
"targetMiddleShort": "Middle short",
"targetBackhandShort": "Backhand short",
"toTarget": "to"
},
"teamManagement": {
"lineupProposal": "Line-up by QTTR",
"eligibility": "Eligibility",
"eligibilityRegular": "Regular",
"eligibilityAdultRelease": "Approved for adults",
"eligibilityAdultReserve": "Adult reserve",
"eligibilityAdultReleaseAndReserve": "Approved + adult reserve",
"selectedLineup": "Selected players",
"availableLineupMembers": "Available players",
"lineupEmpty": "No players have been assigned to this team yet.",
"lineupSaveError": "Team line-up could not be saved.",
"lineupValidationTooLargeGap": "{higher} is more than 30 QTTR points ahead of {lower}. Please correct this order.",
"firstHalf": "First half",
"firstHalfFull": "First half (July - December)",
"secondHalf": "Second half",
"secondHalfFull": "Second half (from 1 January)"
}
}

View File

@@ -4,6 +4,7 @@
"title": "Training Diary"
},
"common": {
"period": "Period",
"loading": "Loading...",
"save": "Save",
"saved": "Saved",
@@ -29,6 +30,7 @@
"time": "Time",
"new": "New",
"update": "Update",
"move": "Move",
"refresh": "Reload",
"create": "Create",
"remove": "Remove",
@@ -724,6 +726,8 @@
"picsInInternetAllowed": "Pictures allowed online",
"testMembership": "Trial membership",
"memberFormHandedOver": "Membership form handed over",
"adultReleaseApproved": "Approved for adults",
"adultReserveApproved": "Adult reserve",
"trainingGroups": "Training groups",
"noGroupsAssigned": "No groups assigned",
"noGroupsAvailable": "No groups available",
@@ -738,7 +742,7 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"adults": "Adults (20+)",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
@@ -1014,6 +1018,22 @@
"playerStats": "Appearances",
"playerStatsIntro": "Quick overview of this team's appearances in the current season.",
"lineupProposal": "Line-up by QTTR",
"teamGender": "Gender",
"teamAgeGroup": "Age group",
"teamGenderOpen": "Open",
"teamGenderFemale": "Female",
"eligibility": "Eligibility",
"eligibilityRegular": "Regular",
"eligibilityAdultRelease": "Approved for adults",
"eligibilityAdultReserve": "Adult reserve",
"eligibilityAdultReleaseAndReserve": "Approved + adult reserve",
"selectedLineup": "Selected players",
"availableLineupMembers": "Available players",
"lineupEmpty": "No players have been assigned to this team yet.",
"lineupUnsavedChanges": "There are unsaved changes in the team line-up.",
"lineupSaved": "Team line-up saved.",
"lineupSaveError": "Team line-up could not be saved.",
"lineupValidationTooLargeGap": "{higher} is more than 30 QTTR points ahead of {lower}. Please correct this order.",
"refreshStats": "Refresh",
"loadingStats": "Loading statistics...",
"noPlayerStats": "No appearances recorded.",

View File

@@ -4,6 +4,7 @@
"title": "Training Diary"
},
"common": {
"period": "Period",
"loading": "Loading...",
"save": "Save",
"saved": "Saved",
@@ -29,6 +30,7 @@
"time": "Time",
"new": "New",
"update": "Update",
"move": "Move",
"refresh": "Reload",
"create": "Create",
"remove": "Remove",
@@ -449,6 +451,8 @@
"picsInInternetAllowed": "Pictures allowed online",
"testMembership": "Trial membership",
"memberFormHandedOver": "Membership form handed over",
"adultReleaseApproved": "Approved for adults",
"adultReserveApproved": "Adult reserve",
"trainingGroups": "Training groups",
"noGroupsAssigned": "No groups assigned",
"noGroupsAvailable": "No groups available",
@@ -463,7 +467,7 @@
"clearFields": "Clear fields",
"showInactiveMembers": "Show inactive members",
"ageGroup": "Age group",
"adults": "Adults (20+)",
"adults": "Adults (18+)",
"j19": "U19 (19 and younger)",
"j17": "U17 (17 and younger)",
"j15": "U15 (15 and younger)",
@@ -868,5 +872,22 @@
"targetMiddleShort": "Middle short",
"targetBackhandShort": "Backhand short",
"toTarget": "to"
},
"teamManagement": {
"lineupProposal": "Line-up by QTTR",
"eligibility": "Eligibility",
"eligibilityRegular": "Regular",
"eligibilityAdultRelease": "Approved for adults",
"eligibilityAdultReserve": "Adult reserve",
"eligibilityAdultReleaseAndReserve": "Approved + adult reserve",
"selectedLineup": "Selected players",
"availableLineupMembers": "Available players",
"lineupEmpty": "No players have been assigned to this team yet.",
"lineupSaveError": "Team line-up could not be saved.",
"lineupValidationTooLargeGap": "{higher} is more than 30 QTTR points ahead of {lower}. Please correct this order.",
"firstHalf": "First half",
"firstHalfFull": "First half (July - December)",
"secondHalf": "Second half",
"secondHalfFull": "Second half (from 1 January)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "semanas",
"months": "meses",
"years": "años",
"ok": "OK"
"ok": "OK",
"period": "Periodo"
},
"navigation": {
"home": "Inicio",
@@ -838,5 +839,22 @@
"targetMiddleShort": "Centro corto",
"targetBackhandShort": "Revés corto",
"toTarget": "a"
},
"teamManagement": {
"lineupProposal": "Alineación por QTTR",
"eligibility": "Elegibilidad",
"eligibilityRegular": "Regular",
"eligibilityAdultRelease": "Autorizado para adultos",
"eligibilityAdultReserve": "Reserva en adultos",
"eligibilityAdultReleaseAndReserve": "Autorizado + reserva en adultos",
"selectedLineup": "Jugadores inscritos",
"availableLineupMembers": "Jugadores disponibles",
"lineupEmpty": "Todavía no se ha inscrito ningún jugador para este equipo.",
"lineupSaveError": "No se pudo guardar la alineación del equipo.",
"lineupValidationTooLargeGap": "{higher} tiene más de 30 puntos QTTR de ventaja sobre {lower}. Corrige este orden.",
"firstHalf": "Primera vuelta",
"firstHalfFull": "Primera vuelta (julio - diciembre)",
"secondHalf": "Segunda vuelta",
"secondHalfFull": "Segunda vuelta (desde el 1 de enero)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "Linggo",
"months": "Buwan",
"years": "Taon",
"ok": "OK"
"ok": "OK",
"period": "Panahon"
},
"navigation": {
"home": "Home",
@@ -838,5 +839,22 @@
"targetMiddleShort": "middle short",
"targetBackhandShort": "backhand short",
"toTarget": "papunta sa"
},
"teamManagement": {
"lineupProposal": "Line-up ayon sa QTTR",
"eligibility": "Pagiging karapat-dapat",
"eligibilityRegular": "Karaniwan",
"eligibilityAdultRelease": "Pinayagan sa adults",
"eligibilityAdultReserve": "Reserve sa adults",
"eligibilityAdultReleaseAndReserve": "Pinayagan + reserve sa adults",
"selectedLineup": "Napiling manlalaro",
"availableLineupMembers": "Magagamit na manlalaro",
"lineupEmpty": "Wala pang manlalarong itinalaga sa koponang ito.",
"lineupSaveError": "Hindi mai-save ang line-up ng koponan.",
"lineupValidationTooLargeGap": "Mahigit 30 QTTR points ang lamang ni {higher} kay {lower}. Pakitama ang ayos na ito.",
"firstHalf": "Unang yugto",
"firstHalfFull": "Unang yugto (Hulyo - Disyembre)",
"secondHalf": "Ikalawang yugto",
"secondHalfFull": "Ikalawang yugto (mula Enero 1)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "semaines",
"months": "mois",
"years": "années",
"ok": "OK"
"ok": "OK",
"period": "Période"
},
"navigation": {
"home": "Accueil",
@@ -838,5 +839,22 @@
"targetMiddleShort": "Milieu court",
"targetBackhandShort": "Revers court",
"toTarget": "vers"
},
"teamManagement": {
"lineupProposal": "Composition selon le QTTR",
"eligibility": "Éligibilité",
"eligibilityRegular": "Régulier",
"eligibilityAdultRelease": "Autorisé chez les adultes",
"eligibilityAdultReserve": "Remplaçant chez les adultes",
"eligibilityAdultReleaseAndReserve": "Autorisé + remplaçant chez les adultes",
"selectedLineup": "Joueurs inscrits",
"availableLineupMembers": "Joueurs disponibles",
"lineupEmpty": "Aucun joueur na encore été inscrit pour cette équipe.",
"lineupSaveError": "La composition de léquipe na pas pu être enregistrée.",
"lineupValidationTooLargeGap": "{higher} a plus de 30 points QTTR davance sur {lower}. Veuillez corriger cet ordre.",
"firstHalf": "Phase aller",
"firstHalfFull": "Phase aller (juillet - décembre)",
"secondHalf": "Phase retour",
"secondHalfFull": "Phase retour (à partir du 1er janvier)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "settimane",
"months": "mesi",
"years": "anni",
"ok": "OK"
"ok": "OK",
"period": "Periodo"
},
"navigation": {
"home": "Home",
@@ -838,5 +839,22 @@
"targetMiddleShort": "Centro corto",
"targetBackhandShort": "Rovescio corto",
"toTarget": "verso"
},
"teamManagement": {
"lineupProposal": "Formazione in base al QTTR",
"eligibility": "Idoneità",
"eligibilityRegular": "Regolare",
"eligibilityAdultRelease": "Autorizzato per adulti",
"eligibilityAdultReserve": "Riserva negli adulti",
"eligibilityAdultReleaseAndReserve": "Autorizzato + riserva negli adulti",
"selectedLineup": "Giocatori schierati",
"availableLineupMembers": "Giocatori disponibili",
"lineupEmpty": "Nessun giocatore è ancora stato assegnato a questa squadra.",
"lineupSaveError": "Impossibile salvare la formazione della squadra.",
"lineupValidationTooLargeGap": "{higher} ha più di 30 punti QTTR di vantaggio su {lower}. Correggi questo ordine.",
"firstHalf": "Girone dandata",
"firstHalfFull": "Girone dandata (luglio - dicembre)",
"secondHalf": "Girone di ritorno",
"secondHalfFull": "Girone di ritorno (dal 1° gennaio)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "週間",
"months": "か月",
"years": "年",
"ok": "OK"
"ok": "OK",
"period": "期間"
},
"navigation": {
"home": "ホーム",
@@ -838,5 +839,22 @@
"targetMiddleShort": "ミドル短",
"targetBackhandShort": "バック短",
"toTarget": "へ"
},
"teamManagement": {
"lineupProposal": "QTTRによるチーム登録",
"eligibility": "出場資格",
"eligibilityRegular": "通常",
"eligibilityAdultRelease": "一般出場許可",
"eligibilityAdultReserve": "一般の補欠",
"eligibilityAdultReleaseAndReserve": "一般出場許可 + 補欠",
"selectedLineup": "登録済み選手",
"availableLineupMembers": "利用可能な選手",
"lineupEmpty": "このチームにはまだ選手が登録されていません。",
"lineupSaveError": "チーム登録を保存できませんでした。",
"lineupValidationTooLargeGap": "{higher} は {lower} より 30 QTTR ポイント以上高いです。この順序を修正してください。",
"firstHalf": "前期",
"firstHalfFull": "前期7月 - 12月",
"secondHalf": "後期",
"secondHalfFull": "後期1月1日以降"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "tygodnie",
"months": "miesiące",
"years": "lata",
"ok": "OK"
"ok": "OK",
"period": "Okres"
},
"navigation": {
"home": "Strona główna",
@@ -838,5 +839,22 @@
"targetMiddleShort": "Środek krótki",
"targetBackhandShort": "Backhand krótki",
"toTarget": "do"
},
"teamManagement": {
"lineupProposal": "Zgłoszenie według QTTR",
"eligibility": "Uprawnienie",
"eligibilityRegular": "Standardowe",
"eligibilityAdultRelease": "Dopuszczony do dorosłych",
"eligibilityAdultReserve": "Rezerwowy u dorosłych",
"eligibilityAdultReleaseAndReserve": "Dopuszczony + rezerwowy u dorosłych",
"selectedLineup": "Zgłoszeni zawodnicy",
"availableLineupMembers": "Dostępni zawodnicy",
"lineupEmpty": "Do tej drużyny nie zgłoszono jeszcze żadnych zawodników.",
"lineupSaveError": "Nie udało się zapisać zgłoszenia drużyny.",
"lineupValidationTooLargeGap": "{higher} ma ponad 30 punktów QTTR przewagi nad {lower}. Popraw tę kolejność.",
"firstHalf": "Pierwsza runda",
"firstHalfFull": "Pierwsza runda (lipiec - grudzień)",
"secondHalf": "Druga runda",
"secondHalfFull": "Druga runda (od 1 stycznia)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "สัปดาห์",
"months": "เดือน",
"years": "ปี",
"ok": "ตกลง"
"ok": "ตกลง",
"period": "ช่วงเวลา"
},
"navigation": {
"home": "หน้าแรก",
@@ -838,5 +839,22 @@
"targetMiddleShort": "กลางสั้น",
"targetBackhandShort": "แบ็กแฮนด์สั้น",
"toTarget": "ไปยัง"
},
"teamManagement": {
"lineupProposal": "จัดทีมตาม QTTR",
"eligibility": "สิทธิ์ลงเล่น",
"eligibilityRegular": "ปกติ",
"eligibilityAdultRelease": "อนุมัติให้เล่นผู้ใหญ่",
"eligibilityAdultReserve": "ตัวสำรองผู้ใหญ่",
"eligibilityAdultReleaseAndReserve": "อนุมัติ + ตัวสำรองผู้ใหญ่",
"selectedLineup": "ผู้เล่นที่ขึ้นทะเบียน",
"availableLineupMembers": "ผู้เล่นที่พร้อมใช้งาน",
"lineupEmpty": "ยังไม่มีการกำหนดผู้เล่นให้ทีมนี้",
"lineupSaveError": "ไม่สามารถบันทึกรายชื่อทีมได้",
"lineupValidationTooLargeGap": "{higher} มีคะแนน QTTR มากกว่า {lower} เกิน 30 คะแนน กรุณาแก้ไขลำดับนี้",
"firstHalf": "ครึ่งแรก",
"firstHalfFull": "ครึ่งแรก (กรกฎาคม - ธันวาคม)",
"secondHalf": "ครึ่งหลัง",
"secondHalfFull": "ครึ่งหลัง (ตั้งแต่ 1 มกราคม)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "Linggo",
"months": "Buwan",
"years": "Taon",
"ok": "OK"
"ok": "OK",
"period": "Panahon"
},
"navigation": {
"home": "Home",
@@ -838,5 +839,22 @@
"targetMiddleShort": "middle short",
"targetBackhandShort": "backhand short",
"toTarget": "papunta sa"
},
"teamManagement": {
"lineupProposal": "Line-up ayon sa QTTR",
"eligibility": "Pagiging karapat-dapat",
"eligibilityRegular": "Karaniwan",
"eligibilityAdultRelease": "Pinayagan sa adults",
"eligibilityAdultReserve": "Reserve sa adults",
"eligibilityAdultReleaseAndReserve": "Pinayagan + reserve sa adults",
"selectedLineup": "Napiling manlalaro",
"availableLineupMembers": "Magagamit na manlalaro",
"lineupEmpty": "Wala pang manlalarong itinalaga sa koponang ito.",
"lineupSaveError": "Hindi mai-save ang line-up ng koponan.",
"lineupValidationTooLargeGap": "Mahigit 30 QTTR points ang lamang ni {higher} kay {lower}. Pakitama ang ayos na ito.",
"firstHalf": "Unang yugto",
"firstHalfFull": "Unang yugto (Hulyo - Disyembre)",
"secondHalf": "Ikalawang yugto",
"secondHalfFull": "Ikalawang yugto (mula Enero 1)"
}
}

View File

@@ -57,7 +57,8 @@
"weeks": "周",
"months": "个月",
"years": "年",
"ok": "确定"
"ok": "确定",
"period": "期间"
},
"navigation": {
"home": "首页",
@@ -838,5 +839,22 @@
"targetMiddleShort": "中路短",
"targetBackhandShort": "反手短",
"toTarget": "到"
},
"teamManagement": {
"lineupProposal": "按 QTTR 的队伍报名",
"eligibility": "参赛资格",
"eligibilityRegular": "常规",
"eligibilityAdultRelease": "允许参加成人组",
"eligibilityAdultReserve": "成人组替补",
"eligibilityAdultReleaseAndReserve": "允许参加成人组 + 替补",
"selectedLineup": "已报名球员",
"availableLineupMembers": "可用球员",
"lineupEmpty": "这支队伍还没有报名任何球员。",
"lineupSaveError": "无法保存队伍报名。",
"lineupValidationTooLargeGap": "{higher} 比 {lower} 高出超过 30 个 QTTR 积分。请更正这个顺序。",
"firstHalf": "上半程",
"firstHalfFull": "上半程7月 - 12月",
"secondHalf": "下半程",
"secondHalfFull": "下半程1月1日起"
}
}

View File

@@ -257,6 +257,8 @@
<label class="checkbox-item"><span>{{ $t('members.picsInInternetAllowed') }}:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
<label class="checkbox-item"><span>{{ $t('members.testMembership') }}:</span> <input type="checkbox" v-model="testMembership"></label>
<label class="checkbox-item"><span>{{ $t('members.memberFormHandedOver') }}:</span> <input type="checkbox" v-model="newMemberFormHandedOver"></label>
<label class="checkbox-item"><span>{{ $t('members.adultReleaseApproved') }}:</span> <input type="checkbox" v-model="newAdultReleaseApproved"></label>
<label class="checkbox-item"><span>{{ $t('members.adultReserveApproved') }}:</span> <input type="checkbox" v-model="newAdultReserveApproved"></label>
<!-- Trainingsgruppen -->
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('training-group') }" v-if="memberToEdit">
@@ -364,6 +366,18 @@
<span v-else-if="member.testMembership && member.trainingParticipations >= 3" class="warning-icon" :title="$t('members.threeOrMoreParticipations')"></span>
<span class="member-meta-text">{{ labelGender(member.gender) }}</span>
</div>
<div v-if="member.adultReleaseApproved || member.adultReserveApproved" class="member-name-flags">
<span
v-if="member.adultReleaseApproved"
class="member-eligibility-flag"
:title="$t('members.adultReleaseApproved')"
>FE</span>
<span
v-if="member.adultReserveApproved"
class="member-eligibility-flag"
:title="$t('members.adultReserveApproved')"
>EE</span>
</div>
</div>
</td>
<td>
@@ -911,6 +925,8 @@ export default {
showInactiveMembers: false,
newPicsInInternetAllowed: false,
newMemberFormHandedOver: false,
newAdultReleaseApproved: false,
newAdultReserveApproved: false,
isUpdatingRatings: false,
showMemberInfo: false,
showActivitiesModal: false,
@@ -1370,6 +1386,8 @@ export default {
this.memberImage = null;
this.memberImagePreview = null;
this.newMemberFormHandedOver = false;
this.newAdultReleaseApproved = false;
this.newAdultReserveApproved = false;
this.memberContacts = {
phones: [{ value: '', isParent: false, parentName: '', isPrimary: false }],
emails: [{ value: '', isParent: false, parentName: '', isPrimary: false }]
@@ -1589,6 +1607,8 @@ export default {
testMembership: this.testMembership,
picsInInternetAllowed: this.newPicsInInternetAllowed,
memberFormHandedOver: this.newMemberFormHandedOver,
adultReleaseApproved: this.newAdultReleaseApproved,
adultReserveApproved: this.newAdultReserveApproved,
contacts: contacts
};
@@ -1637,6 +1657,8 @@ export default {
this.testMembership = member.testMembership;
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
this.newMemberFormHandedOver = !!member.memberFormHandedOver;
this.newAdultReleaseApproved = !!member.adultReleaseApproved;
this.newAdultReserveApproved = !!member.adultReserveApproved;
// Load contacts
if (member.contacts && Array.isArray(member.contacts)) {
@@ -3574,6 +3596,30 @@ table td {
flex-wrap: wrap;
}
.member-name-flags {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.18rem;
}
.member-eligibility-flag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: #eef6ff;
color: #12427a;
border: 1px solid #bfd7f3;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.1;
}
.member-meta-text {
color: #66788a;
font-size: 0.88rem;

View File

@@ -66,6 +66,9 @@
<button v-if="teamToEdit && teamToEdit.leagueId" type="button" class="workspace-section-button" :class="{ active: activeEditorSection === 'stats' }" @click="activeEditorSection = 'stats'">
{{ t('teamManagement.playerStats') }}
</button>
<button v-if="teamToEdit" type="button" class="workspace-section-button" :class="{ active: activeEditorSection === 'lineup' }" @click="activeEditorSection = 'lineup'">
{{ t('teamManagement.lineupProposal') }}
</button>
<button v-if="teamToEdit" type="button" class="workspace-section-button" :class="{ active: activeEditorSection === 'documents' }" @click="activeEditorSection = 'documents'">
{{ t('teamManagement.documents') }}
</button>
@@ -97,6 +100,14 @@
<span class="settings-summary-label">{{ t('teamManagement.season') }}</span>
<strong>{{ teamToEdit.season?.season || t('teamManagement.seasonUnknown') }}</strong>
</div>
<div class="settings-summary-card">
<span class="settings-summary-label">{{ t('teamManagement.teamGender') }}</span>
<strong>{{ labelTeamGender(effectiveTeamGender) }}</strong>
</div>
<div class="settings-summary-card">
<span class="settings-summary-label">{{ t('teamManagement.teamAgeGroup') }}</span>
<strong>{{ labelAgeGroup(effectiveTeamAgeGroup) }}</strong>
</div>
</div>
<div class="settings-form-grid">
<label>
@@ -113,6 +124,23 @@
</option>
</select>
</label>
<label>
<span>{{ t('teamManagement.teamGender') }}:</span>
<select v-model="newTeamGender">
<option value="open">{{ t('teamManagement.teamGenderOpen') }}</option>
<option value="female">{{ t('teamManagement.teamGenderFemale') }}</option>
</select>
</label>
<label>
<span>{{ t('teamManagement.teamAgeGroup') }}:</span>
<select v-model="newTeamAgeGroup">
<option v-for="option in teamAgeGroupOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<div class="form-actions">
<button @click="addNewTeam" :disabled="!newTeamName.trim()">
@@ -184,36 +212,117 @@
</tbody>
</table>
<div v-if="lineupProposalGroups.length" class="lineup-proposal-card">
<div class="lineup-proposal-header">
<strong>{{ t('teamManagement.lineupProposal') }}</strong>
<span>{{ lineupProposalMemberCount }}</span>
</div>
<div v-if="teamToEdit && activeEditorSection === 'lineup'" class="workspace-section-panel player-stats">
<div class="workspace-panel-header">
<div>
<span class="section-title">📋 {{ t('teamManagement.lineupProposal') }}</span>
</div>
<div class="lineup-proposal-groups">
<section v-for="group in lineupProposalGroups" :key="group.code" class="lineup-proposal-group">
<div class="lineup-proposal-group-head">
<strong>{{ group.label }}</strong>
<span>{{ group.members.length }}</span>
</div>
<table class="lineup-proposal-table">
<div class="lineup-toolbar">
<div class="lineup-period-meta">
<span class="lineup-period-chip">
{{ t('teamManagement.season') }}: {{ teamToEdit?.season?.season || currentSeason?.season || t('teamManagement.seasonUnknown') }}
</span>
<label class="lineup-period-select">
<span>{{ t('common.period') }}</span>
<select v-model="selectedLineupHalf" :disabled="savingTeamLineup || loadingTeamLineup">
<option v-for="option in lineupHalfOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<button @click="loadClubMembers(); loadTeamLineup();" :disabled="loadingLineupMembers || loadingTeamLineup" class="btn-sm">
{{ (loadingLineupMembers || loadingTeamLineup) ? '⏳' : '🔄' }} {{ t('teamManagement.refreshStats') }}
</button>
</div>
</div>
<div class="lineup-editor-grid">
<div class="lineup-proposal-card">
<div class="lineup-proposal-header">
<strong>{{ t('teamManagement.selectedLineup') }}</strong>
<span>{{ selectedTeamLineupMembers.length }}</span>
</div>
<table v-if="selectedTeamLineupMembers.length" class="lineup-proposal-table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>{{ t('teamManagement.player') }}</th>
<th>{{ t('members.ageGroup') }}</th>
<th :title="t('teamManagement.qttr')">(Q)TTR</th>
<th>{{ t('teamManagement.eligibility') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody :key="lineupRenderKey" ref="lineupSortableBody">
<tr v-for="(member, index) in selectedTeamLineupMembers" :key="member.id" class="team-lineup-row">
<td class="lineup-drag-cell">
<span class="drag-handle" :title="t('common.move')"></span>
</td>
<td class="lineup-rank">{{ index + 1 }}</td>
<td>{{ member.firstName }} {{ member.lastName }}</td>
<td>{{ member.memberAgeGroupLabel }}</td>
<td class="lineup-rating">{{ member.lineupRatingLabel }}</td>
<td>{{ member.eligibilityLabel }}</td>
<td class="lineup-actions-cell">
<button type="button" class="btn-secondary btn-upload-sm" @click="moveLineupMember(member.id, 'up')" :disabled="savingTeamLineup || index === 0"></button>
<button type="button" class="btn-secondary btn-upload-sm" @click="moveLineupMember(member.id, 'down')" :disabled="savingTeamLineup || index === selectedTeamLineupMembers.length - 1"></button>
<button type="button" class="btn-secondary btn-upload-sm" @click="removeMemberFromLineup(member.id)" :disabled="savingTeamLineup"></button>
</td>
</tr>
</tbody>
</table>
<div v-else class="no-stats">
{{ t('teamManagement.lineupEmpty') }}
</div>
</div>
<div v-if="lineupProposalGroups.length" class="lineup-proposal-card">
<div class="lineup-proposal-header">
<strong>{{ t('teamManagement.availableLineupMembers') }}</strong>
<span>{{ availableLineupMembers.length }}</span>
</div>
<div class="lineup-proposal-groups">
<section v-for="group in lineupProposalGroups" :key="group.code" class="lineup-proposal-group">
<div class="lineup-proposal-group-head">
<strong>{{ group.label }}</strong>
<span>{{ group.members.filter((member) => !selectedTeamLineupMembers.some((selected) => selected.id === member.id)).length }}</span>
</div>
<table class="lineup-proposal-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('teamManagement.player') }}</th>
<th>{{ t('members.ageGroup') }}</th>
<th :title="t('teamManagement.qttr')">(Q)TTR</th>
<th>{{ t('teamManagement.eligibility') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(member, index) in group.members" :key="member.id">
<td class="lineup-rank">{{ index + 1 }}</td>
<tr v-for="member in group.members.filter((entry) => !selectedTeamLineupMembers.some((selected) => selected.id === entry.id))" :key="member.id">
<td>{{ member.firstName }} {{ member.lastName }}</td>
<td>{{ member.memberAgeGroupLabel }}</td>
<td class="lineup-rating">{{ member.lineupRatingLabel }}</td>
<td>{{ member.eligibilityLabel }}</td>
<td class="lineup-actions-cell">
<button type="button" class="btn-secondary btn-upload-sm" @click="addMemberToLineup(member)" :disabled="savingTeamLineup">+</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
<div v-if="!lineupProposalGroups.length" class="no-stats">
{{ t('teamManagement.noPlayerStats') }}
</div>
</div>
<div v-if="teamToEdit && activeEditorSection === 'documents'" class="workspace-section-panel advanced-settings">
@@ -409,12 +518,13 @@
</template>
<script>
import { ref, onMounted, computed, watch } from 'vue';
import { ref, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import SeasonSelector from '../components/SeasonSelector.vue';
import apiClient from '../apiClient.js';
import { getSafeErrorMessage, getSafeMessage } from '../utils/errorMessages.js';
import i18n from '../i18n';
import Sortable from 'sortablejs';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
@@ -464,6 +574,8 @@ export default {
const teamToEdit = ref(null);
const newTeamName = ref('');
const newLeagueId = ref('');
const newTeamGender = ref('open');
const newTeamAgeGroup = ref('adult');
const selectedSeasonId = ref(null);
const currentSeason = ref(null);
const teamDocuments = ref([]);
@@ -491,8 +603,16 @@ export default {
// Player Stats
const playerStats = ref([]);
const loadingStats = ref(false);
const loadingLineupMembers = ref(false);
const loadingTeamLineup = ref(false);
const savingTeamLineup = ref(false);
const memberById = ref({});
const clubMembers = ref([]);
const teamLineupAssignments = ref([]);
const lineupSortableBody = ref(null);
const lineupRenderKey = ref(0);
const selectedLineupHalf = ref('first_half');
let lineupSortableInstance = null;
// Scheduler Jobs Info
const schedulerJobs = ref({
@@ -516,6 +636,14 @@ export default {
const teamsWithoutLeagueCount = computed(() => teams.value.filter(team => !team.leagueId).length);
const totalSeasonAppearances = computed(() => playerStats.value.reduce((sum, stat) => sum + (Number(stat.totalSeason) || 0), 0));
const totalHalfAppearances = computed(() => playerStats.value.reduce((sum, stat) => sum + (Number(isSecondHalf.value ? stat.totalSecondHalf : stat.totalFirstHalf) || 0), 0));
const lineupHalfOptions = computed(() => ([
{ value: 'first_half', label: t('teamManagement.firstHalfFull') },
{ value: 'second_half', label: t('teamManagement.secondHalfFull') }
]));
const teamAgeGroupOptions = computed(() => ['adult', 'J19', 'J17', 'J15', 'J13', 'J11'].map((value) => ({
value,
label: value === 'adult' ? t('members.adults') : t(`members.${value.toLowerCase()}`)
})));
const filteredTeams = computed(() => {
const search = teamSearchQuery.value.trim().toLowerCase();
return teams.value.filter(team => {
@@ -550,22 +678,105 @@ export default {
return 'adult';
};
const parseLeagueGenderCode = (leagueName) => {
const source = String(leagueName || '').toLowerCase();
return /(frauen|damen|mädchen|girls|women)/i.test(source) ? 'female' : 'open';
};
const getConfiguredTeamAgeGroup = (team) => team?.teamAgeGroup || parseLeagueAgeGroupCode(team?.league?.name);
const getConfiguredTeamGender = (team) => team?.teamGender || parseLeagueGenderCode(team?.league?.name);
const effectiveTeamAgeGroup = computed(() => getConfiguredTeamAgeGroup(teamToEdit.value));
const effectiveTeamGender = computed(() => getConfiguredTeamGender(teamToEdit.value));
const getSeasonReferenceYear = () => {
const seasonLabel = String(currentSeason.value?.season || teamToEdit.value?.season?.season || '').trim();
const fourDigitRange = seasonLabel.match(/(\d{4})\s*\/\s*(\d{4})/);
if (fourDigitRange) {
return Number(fourDigitRange[2]);
}
const mixedRange = seasonLabel.match(/(\d{4})\s*\/\s*(\d{2})/);
if (mixedRange) {
const startYear = Number(mixedRange[1]);
const endTwoDigits = Number(mixedRange[2]);
const century = Math.floor(startYear / 100) * 100;
return century + endTwoDigits;
}
const singleYear = seasonLabel.match(/(\d{4})/);
if (singleYear) {
return Number(singleYear[1]);
}
return new Date().getFullYear();
};
const parseMemberBirthDate = (value) => {
if (!value || typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
const dotted = trimmed.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (dotted) {
const year = Number(dotted[3]);
const month = Number(dotted[2]) - 1;
const day = Number(dotted[1]);
const parsed = new Date(year, month, day);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
const isoDate = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoDate) {
const year = Number(isoDate[1]);
const month = Number(isoDate[2]) - 1;
const day = Number(isoDate[3]);
const parsed = new Date(year, month, day);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
const parsed = new Date(trimmed);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const getMemberActualAge = (member) => {
const birthDate = parseMemberBirthDate(member?.birthDate);
if (!birthDate) {
return null;
}
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const hadBirthdayThisYear =
today.getMonth() > birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
if (!hadBirthdayThisYear) {
age -= 1;
}
return age;
};
const getMemberSeasonAge = (member) => {
const birthDate = parseMemberBirthDate(member?.birthDate);
if (!birthDate) {
return null;
}
return getSeasonReferenceYear() - birthDate.getFullYear();
};
const getMemberAgeGroupCode = (member) => {
if (!member?.birthDate) {
const seasonAge = getMemberSeasonAge(member);
if (seasonAge === null) {
return 'unknown';
}
const birthDate = new Date(member.birthDate);
if (Number.isNaN(birthDate.getTime())) {
return 'unknown';
}
const ageByBirthYear = new Date().getFullYear() - birthDate.getFullYear();
if (ageByBirthYear <= 11) return 'J11';
if (ageByBirthYear <= 13) return 'J13';
if (ageByBirthYear <= 15) return 'J15';
if (ageByBirthYear <= 17) return 'J17';
if (ageByBirthYear <= 19) return 'J19';
if (seasonAge <= 11) return 'J11';
if (seasonAge <= 13) return 'J13';
if (seasonAge <= 15) return 'J15';
if (seasonAge <= 17) return 'J17';
if (seasonAge <= 19) return 'J19';
return 'adult';
};
@@ -575,6 +786,57 @@ export default {
return code;
};
const labelAgeGroup = (code) => getMemberAgeGroupLabel(code);
const labelTeamGender = (gender) => gender === 'female' ? t('teamManagement.teamGenderFemale') : t('teamManagement.teamGenderOpen');
const isFemaleEligible = (member, teamGender) => {
if (teamGender !== 'female') return true;
return member?.gender === 'female';
};
const isEligibleForTeam = (member, teamAgeGroup, teamGender) => {
if (!member?.active || !isFemaleEligible(member, teamGender)) {
return false;
}
const memberAgeGroup = getMemberAgeGroupCode(member);
const memberActualAge = getMemberActualAge(member);
if (memberAgeGroup === 'unknown') {
return false;
}
if (teamAgeGroup === 'adult') {
if (memberActualAge !== null && memberActualAge >= 18) {
return true;
}
return !!member?.adultReleaseApproved || !!member?.adultReserveApproved;
}
const order = ['J11', 'J13', 'J15', 'J17', 'J19'];
const memberIndex = order.indexOf(memberAgeGroup);
const teamIndex = order.indexOf(teamAgeGroup);
if (memberIndex < 0 || teamIndex < 0) {
return false;
}
return memberIndex <= teamIndex;
};
const getMemberEligibilityLabel = (member, teamAgeGroup) => {
if (teamAgeGroup !== 'adult' || getMemberAgeGroupCode(member) === 'adult') {
return t('teamManagement.eligibilityRegular');
}
if (member?.adultReleaseApproved && member?.adultReserveApproved) {
return t('teamManagement.eligibilityAdultReleaseAndReserve');
}
if (member?.adultReleaseApproved) {
return t('teamManagement.eligibilityAdultRelease');
}
if (member?.adultReserveApproved) {
return t('teamManagement.eligibilityAdultReserve');
}
return t('teamManagement.eligibilityRegular');
};
const getMemberLineupRatingValue = (member) => {
const qttr = Number(member?.qttr);
if (Number.isFinite(qttr)) return qttr;
@@ -589,13 +851,17 @@ export default {
};
const lineupProposalGroups = computed(() => {
const members = (clubMembers.value || []).filter((member) => member?.active);
const teamAgeGroup = effectiveTeamAgeGroup.value;
const teamGender = effectiveTeamGender.value;
const members = (clubMembers.value || []).filter((member) => isEligibleForTeam(member, teamAgeGroup, teamGender));
if (!members.length) {
return [];
}
const preferredAgeGroup = parseLeagueAgeGroupCode(teamToEdit.value?.league?.name);
const defaultOrder = ['adult', 'J19', 'J17', 'J15', 'J13', 'J11', 'unknown'];
const preferredAgeGroup = teamAgeGroup;
const defaultOrder = teamAgeGroup === 'adult'
? ['adult', 'J19', 'J17', 'J15', 'J13', 'J11', 'unknown']
: [teamAgeGroup, ...['J19', 'J17', 'J15', 'J13', 'J11'].filter((entry) => entry !== teamAgeGroup), 'unknown'];
const groupOrder = defaultOrder.includes(preferredAgeGroup)
? [preferredAgeGroup, ...defaultOrder.filter((entry) => entry !== preferredAgeGroup)]
: defaultOrder;
@@ -612,7 +878,9 @@ export default {
}
groups.get(code).members.push({
...member,
lineupRatingLabel: getMemberLineupRatingLabel(member)
lineupRatingLabel: getMemberLineupRatingLabel(member),
memberAgeGroupLabel: getMemberAgeGroupLabel(code),
eligibilityLabel: getMemberEligibilityLabel(member, teamAgeGroup)
});
});
@@ -636,6 +904,40 @@ export default {
});
});
const lineupProposalMemberCount = computed(() => lineupProposalGroups.value.reduce((sum, group) => sum + group.members.length, 0));
const eligibleLineupMembers = computed(() => lineupProposalGroups.value.flatMap((group) => group.members));
const selectedTeamLineupMembers = computed(() => {
const memberMap = new Map(eligibleLineupMembers.value.map((member) => [Number(member.id), member]));
return [...teamLineupAssignments.value]
.sort((a, b) => a.position - b.position)
.map((entry) => {
const member = memberMap.get(Number(entry.memberId));
return member ? { ...member, position: entry.position } : null;
})
.filter(Boolean);
});
const availableLineupMembers = computed(() => {
const selectedIds = new Set(teamLineupAssignments.value.map((entry) => Number(entry.memberId)));
return eligibleLineupMembers.value.filter((member) => !selectedIds.has(Number(member.id)));
});
const teamLineupValidationMessage = computed(() => {
const selected = selectedTeamLineupMembers.value;
for (let index = 0; index < selected.length - 1; index += 1) {
const currentMember = selected[index];
const nextMember = selected[index + 1];
const currentRating = getMemberLineupRatingValue(currentMember);
const nextRating = getMemberLineupRatingValue(nextMember);
if (!Number.isFinite(currentRating) || !Number.isFinite(nextRating)) {
continue;
}
if (nextRating > currentRating + 30) {
return t('teamManagement.lineupValidationTooLargeGap', {
higher: `${nextMember.firstName} ${nextMember.lastName}`,
lower: `${currentMember.firstName} ${currentMember.lastName}`
});
}
}
return '';
});
// Methods
const toggleNewTeam = () => {
@@ -654,6 +956,8 @@ export default {
const resetNewTeam = () => {
newTeamName.value = '';
newLeagueId.value = '';
newTeamGender.value = 'open';
newTeamAgeGroup.value = 'adult';
activeEditorSection.value = 'basic';
};
@@ -695,7 +999,9 @@ export default {
const teamData = {
name: newTeamName.value.trim(),
leagueId: newLeagueId.value || null,
seasonId: selectedSeasonId.value
seasonId: selectedSeasonId.value,
teamGender: newTeamGender.value,
teamAgeGroup: newTeamAgeGroup.value
};
if (teamToEdit.value) {
@@ -727,6 +1033,8 @@ export default {
teamToEdit.value = team;
newTeamName.value = team.name;
newLeagueId.value = team.leagueId || '';
newTeamGender.value = getConfiguredTeamGender(team);
newTeamAgeGroup.value = getConfiguredTeamAgeGroup(team);
teamFormIsOpen.value = true;
activeEditorSection.value = 'basic';
await loadTeamDocuments();
@@ -1362,6 +1670,270 @@ export default {
};
};
const loadClubMembers = async () => {
if (!teamToEdit.value) {
return;
}
loadingLineupMembers.value = true;
try {
const membersResp = await apiClient.get(`/clubmembers/get/${selectedClub.value}/true`);
const map = {};
clubMembers.value = membersResp.data || [];
for (const m of clubMembers.value) {
map[m.id] = { ttr: m.ttr ?? null, qttr: m.qttr ?? null };
}
memberById.value = map;
} catch (e) {
clubMembers.value = [];
memberById.value = {};
} finally {
loadingLineupMembers.value = false;
}
};
const normalizeTeamLineupAssignments = (assignments) => assignments
.filter((entry) => entry && entry.memberId)
.map((entry, index) => ({
memberId: Number(entry.memberId),
position: index + 1
}));
const getLineupValidationMessageForAssignments = (assignments) => {
const memberMap = new Map(eligibleLineupMembers.value.map((member) => [Number(member.id), member]));
const selected = normalizeTeamLineupAssignments(assignments)
.map((entry) => memberMap.get(Number(entry.memberId)))
.filter(Boolean);
for (let index = 0; index < selected.length - 1; index += 1) {
const currentMember = selected[index];
const nextMember = selected[index + 1];
const currentRating = getMemberLineupRatingValue(currentMember);
const nextRating = getMemberLineupRatingValue(nextMember);
if (!Number.isFinite(currentRating) || !Number.isFinite(nextRating)) {
continue;
}
if (nextRating > currentRating + 30) {
return t('teamManagement.lineupValidationTooLargeGap', {
higher: `${nextMember.firstName} ${nextMember.lastName}`,
lower: `${currentMember.firstName} ${currentMember.lastName}`
});
}
}
return '';
};
const getLineupMoveValidationMessage = (currentAssignments, oldIndex, newIndex) => {
const normalizedAssignments = normalizeTeamLineupAssignments(currentAssignments);
const memberMap = new Map(eligibleLineupMembers.value.map((member) => [Number(member.id), member]));
const movedEntry = normalizedAssignments[oldIndex];
if (!movedEntry) {
return '';
}
const movedMember = memberMap.get(Number(movedEntry.memberId));
const movedRating = getMemberLineupRatingValue(movedMember);
if (!Number.isFinite(movedRating)) {
return '';
}
const rangeStart = Math.min(oldIndex, newIndex);
const rangeEnd = Math.max(oldIndex, newIndex);
const crossedEntries = normalizedAssignments.slice(rangeStart, rangeEnd + 1)
.filter((_, index) => (rangeStart + index) !== oldIndex);
for (const crossedEntry of crossedEntries) {
const crossedMember = memberMap.get(Number(crossedEntry.memberId));
const crossedRating = getMemberLineupRatingValue(crossedMember);
if (!Number.isFinite(crossedRating)) {
continue;
}
if (newIndex < oldIndex) {
if (crossedRating > movedRating + 30) {
return t('teamManagement.lineupValidationTooLargeGap', {
higher: `${crossedMember.firstName} ${crossedMember.lastName}`,
lower: `${movedMember.firstName} ${movedMember.lastName}`
});
}
} else if (movedRating > crossedRating + 30) {
return t('teamManagement.lineupValidationTooLargeGap', {
higher: `${movedMember.firstName} ${movedMember.lastName}`,
lower: `${crossedMember.firstName} ${crossedMember.lastName}`
});
}
}
return '';
};
const loadTeamLineup = async () => {
if (!teamToEdit.value?.id) {
teamLineupAssignments.value = [];
lineupRenderKey.value += 1;
return;
}
loadingTeamLineup.value = true;
try {
const response = await apiClient.get(`/club-teams/${teamToEdit.value.id}/lineup`, {
params: { half: selectedLineupHalf.value }
});
if (response.status >= 400) {
throw new Error('lineuploadfailed');
}
const assignments = Array.isArray(response.data)
? response.data.map((entry) => ({
memberId: Number(entry.memberId),
position: Number(entry.position)
}))
: [];
teamLineupAssignments.value = normalizeTeamLineupAssignments(assignments);
lineupRenderKey.value += 1;
} catch (error) {
console.error('Fehler beim Laden der Mannschaftsmeldung:', error);
teamLineupAssignments.value = [];
lineupRenderKey.value += 1;
} finally {
loadingTeamLineup.value = false;
}
};
const persistTeamLineupAssignments = async (nextAssignments, validationMessage = '') => {
if (!teamToEdit.value?.id || savingTeamLineup.value) {
return false;
}
const normalizedAssignments = normalizeTeamLineupAssignments(nextAssignments);
if (validationMessage) {
lineupRenderKey.value += 1;
await nextTick();
initializeLineupSortable();
await showInfo(t('messages.warning'), validationMessage, '', 'warning');
return false;
}
const previousAssignments = [...teamLineupAssignments.value];
teamLineupAssignments.value = normalizedAssignments;
savingTeamLineup.value = true;
try {
const response = await apiClient.put(`/club-teams/${teamToEdit.value.id}/lineup`, {
assignments: normalizedAssignments,
lineupHalf: selectedLineupHalf.value
});
if (response.status >= 400) {
throw new Error('lineupsavefailed');
}
teamLineupAssignments.value = normalizedAssignments;
lineupRenderKey.value += 1;
await nextTick();
initializeLineupSortable();
return true;
} catch (error) {
console.error('Fehler beim Speichern der Mannschaftsmeldung:', error);
teamLineupAssignments.value = previousAssignments;
lineupRenderKey.value += 1;
await nextTick();
initializeLineupSortable();
await showInfo(t('messages.error'), t('teamManagement.lineupSaveError'), '', 'error');
return false;
} finally {
savingTeamLineup.value = false;
}
};
const addMemberToLineup = async (member) => {
if (!member?.id || savingTeamLineup.value) {
return;
}
if (teamLineupAssignments.value.some((entry) => Number(entry.memberId) === Number(member.id))) {
return;
}
const nextAssignments = [
...teamLineupAssignments.value,
{ memberId: Number(member.id) }
];
const validationMessage = getLineupValidationMessageForAssignments(nextAssignments);
await persistTeamLineupAssignments(nextAssignments, validationMessage);
};
const removeMemberFromLineup = async (memberId) => {
if (savingTeamLineup.value) {
return;
}
await persistTeamLineupAssignments(
teamLineupAssignments.value.filter((entry) => Number(entry.memberId) !== Number(memberId))
);
};
const moveLineupMember = async (memberId, direction) => {
if (savingTeamLineup.value) {
return;
}
const currentAssignments = [...teamLineupAssignments.value].sort((a, b) => a.position - b.position);
const currentIndex = currentAssignments.findIndex((entry) => Number(entry.memberId) === Number(memberId));
if (currentIndex < 0) {
return;
}
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= currentAssignments.length) {
return;
}
const [entry] = currentAssignments.splice(currentIndex, 1);
currentAssignments.splice(targetIndex, 0, entry);
const validationMessage = getLineupMoveValidationMessage(
[...teamLineupAssignments.value].sort((a, b) => a.position - b.position),
currentIndex,
targetIndex
);
await persistTeamLineupAssignments(currentAssignments, validationMessage);
};
const destroyLineupSortable = () => {
if (lineupSortableInstance) {
lineupSortableInstance.destroy();
lineupSortableInstance = null;
}
};
const initializeLineupSortable = async () => {
await nextTick();
destroyLineupSortable();
if (activeEditorSection.value !== 'lineup' || !lineupSortableBody.value || selectedTeamLineupMembers.value.length < 2) {
return;
}
lineupSortableInstance = Sortable.create(lineupSortableBody.value, {
draggable: '.team-lineup-row',
handle: '.drag-handle',
animation: 150,
onEnd: async (event) => {
if (savingTeamLineup.value || event.oldIndex == null || event.newIndex == null || event.oldIndex === event.newIndex) {
return;
}
const currentAssignments = [...teamLineupAssignments.value].sort((a, b) => a.position - b.position);
const [movedEntry] = currentAssignments.splice(event.oldIndex, 1);
currentAssignments.splice(event.newIndex, 0, movedEntry);
const validationMessage = getLineupMoveValidationMessage(
[...teamLineupAssignments.value].sort((a, b) => a.position - b.position),
event.oldIndex,
event.newIndex
);
const success = await persistTeamLineupAssignments(currentAssignments, validationMessage);
if (!success) {
await nextTick();
initializeLineupSortable();
}
}
});
};
const refreshPlayerStats = async () => {
if (!teamToEdit.value || !teamToEdit.value.leagueId) {
return;
@@ -1370,20 +1942,7 @@ export default {
loadingStats.value = true;
try {
// Mitglieder mit (Q)TTR laden, um Werte anzuzeigen
try {
const membersResp = await apiClient.get(`/clubmembers/get/${selectedClub.value}/true`);
const map = {};
clubMembers.value = membersResp.data || [];
for (const m of clubMembers.value) {
map[m.id] = { ttr: m.ttr ?? null, qttr: m.qttr ?? null };
}
memberById.value = map;
} catch (e) {
clubMembers.value = [];
memberById.value = {};
}
await loadClubMembers();
const response = await apiClient.get(
`/matches/leagues/${selectedClub.value}/stats/${teamToEdit.value.leagueId}`,
{
@@ -1495,12 +2054,40 @@ export default {
// Watch teamToEdit to load stats when a team is selected
watch(teamToEdit, (newTeam) => {
if (newTeam) {
selectedLineupHalf.value = isSecondHalf.value ? 'second_half' : 'first_half';
loadClubMembers();
loadTeamLineup();
} else {
clubMembers.value = [];
memberById.value = {};
teamLineupAssignments.value = [];
}
if (newTeam && newTeam.leagueId) {
refreshPlayerStats();
} else {
playerStats.value = [];
}
});
watch(selectedLineupHalf, () => {
if (teamToEdit.value?.id) {
loadTeamLineup();
}
});
watch(activeEditorSection, () => {
initializeLineupSortable();
});
watch(selectedTeamLineupMembers, () => {
initializeLineupSortable();
}, { deep: true });
onBeforeUnmount(() => {
destroyLineupSortable();
});
// Watch selectedSeasonId to load teams when season changes
watch(selectedSeasonId, (newSeasonId) => {
@@ -1557,6 +2144,8 @@ export default {
teamToEdit,
newTeamName,
newLeagueId,
newTeamGender,
newTeamAgeGroup,
selectedSeasonId,
currentSeason,
teamDocuments,
@@ -1579,6 +2168,12 @@ export default {
myTischtennisSuccess,
playerStats,
loadingStats,
loadingLineupMembers,
loadingTeamLineup,
savingTeamLineup,
lineupSortableBody,
lineupRenderKey,
selectedLineupHalf,
filteredLeagues,
isSecondHalf,
fullyConfiguredTeamsCount,
@@ -1586,8 +2181,18 @@ export default {
teamsWithoutLeagueCount,
totalSeasonAppearances,
totalHalfAppearances,
lineupHalfOptions,
teamAgeGroupOptions,
effectiveTeamAgeGroup,
effectiveTeamGender,
lineupProposalGroups,
lineupProposalMemberCount,
eligibleLineupMembers,
selectedTeamLineupMembers,
availableLineupMembers,
teamLineupValidationMessage,
labelAgeGroup,
labelTeamGender,
toggleNewTeam,
resetToNewTeam,
resetNewTeam,
@@ -1612,8 +2217,13 @@ export default {
clearParsedData,
getMyTischtennisStatus,
fetchTeamDataManually,
refreshPlayerStats
,memberById,
refreshPlayerStats,
loadClubMembers,
loadTeamLineup,
addMemberToLineup,
removeMemberFromLineup,
moveLineupMember,
memberById,
schedulerJobs,
formatJobDate,
loadSchedulerJobsInfo,
@@ -2899,6 +3509,43 @@ export default {
font-size: 0.9rem;
}
.lineup-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.lineup-period-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.lineup-period-chip {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(47, 122, 95, 0.08);
color: var(--primary-color);
font-weight: 600;
}
.lineup-period-select {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color);
font-weight: 600;
}
.lineup-period-select select {
min-width: 220px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
@@ -2994,6 +3641,26 @@ export default {
font-variant-numeric: tabular-nums;
}
.lineup-drag-cell {
width: 2.4rem;
text-align: center;
}
.drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
color: #66788a;
font-weight: 700;
user-select: none;
}
.team-lineup-row.sortable-chosen .drag-handle,
.team-lineup-row.sortable-ghost .drag-handle {
cursor: grabbing;
}
/* Legacy styles (can be removed if not used elsewhere) */
.mytischtennis-header {
display: flex;