feat(TeamManagement): enhance team management features and introduce planning phase for Android
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated TODO.md to outline the new planning phase for Android, aligning it with existing web functionalities for team management. - Refactored AppDependencies to include TeamDocumentsApi, improving API integration for team-related documents. - Replaced MobileTeamsScreen with TeamManagementScreen in ClubStammdatenScreens for better navigation. - Enhanced TeamManagementScreen with improved state management and UI updates for team editing and data loading. - Added new API methods in ClubTeamsApi for managing team lineups, supporting better team planning and organization. - Introduced new methods in MatchesApi and MyTischtennisApi to enhance match and team data handling.
This commit is contained in:
@@ -164,10 +164,51 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien
|
||||
## Phase 8 – Freigaben & Verwaltung
|
||||
|
||||
- [x] **Ausstehende Freigaben** – `ClubApprovalsApi`, `PendingApprovalsManager`, Screen unter „Mehr“ → Club-Verwaltung (`ClubAdminScreens.kt`)
|
||||
- [x] **Team-Management** – Deep-Link `openBackendPath("/team-management")` bei `canReadTeams()` (volle Parität zur Web-`TeamManagementView` bewusst nicht mobil)
|
||||
- [x] **Team-Management (Mannschaften + Editor)** – `TeamManagementScreen` + `TeamEditorScreen`: Saison, Suche, CRUD Mannschaften; Tabs Stammdaten, Spielerstatistik (Liga), **Aufstellung/Meldung**, Dokumente, Scheduler-Jobs, MyTT (`ClubTeamsApi`, `MatchesApi`, `TeamDocumentsApi`, `ApiLogsApi`, `MyTischtennisApi`, Rechte `canReadTeams`/`canWriteTeams`)
|
||||
- [ ] **Mannschafts-Planung (Android)** – siehe Unterabschnitt **Phase 8a** (Web hat das Planungsboard bereits; mobil bewusst **offen**)
|
||||
- [x] **Berechtigungen** – erweiterte `PermissionsApi`, `PermissionsAdminManager`, UI Rolle/Status/Anpassen mit `RolePermissionMatrix` (`ClubAdminScreens.kt`)
|
||||
- [x] **Logs** – `ApiLogsApi`, `ApiLogsManager`, Liste + Pagination + Detail (`ClubAdminScreens.kt`); `AppDependencies` / Logout / 401 räumen Manager auf
|
||||
|
||||
### Phase 8a – Mannschafts-Planung auf Android (Backlog / Plan)
|
||||
|
||||
**Ziel:** Gleiche **fachliche** Funktion wie Web **Team-Verwaltung → Planung**: Pool „möchte spielen“, mehrere Mannschaften der Saison parallel, Spieler nur **eligibility-konform** zuordnen, lokale Stammdaten (Name, geplante Liga, AK/GK) pro Team, **Meldung (Lineup)** je Team und gewählter **Halbserie** lesen/schreiben, optional **Debounced Autosave** wie Web.
|
||||
|
||||
**Web-Referenz (1:1 zum Durchlesen der Logik):**
|
||||
|
||||
| Bereich | Datei / Abschnitt |
|
||||
|--------|-------------------|
|
||||
| Umschalter „Mannschaften“ / „Planung“, Board einbinden | `frontend/src/views/TeamManagementView.vue` (`activeMainSection`, `TeamPlanningBoard`) |
|
||||
| UI Pool, Teams, Suche, Interesse markieren | `frontend/src/components/team/TeamPlanningBoard.vue` |
|
||||
| Lanes / Drag-Drop | `frontend/src/components/team/TeamPlanningLane.vue` |
|
||||
|
||||
**Backend-APIs (aus Web `apiClient`; im `shared` ergänzen, falls noch fehlend):**
|
||||
|
||||
- [ ] `GET /api/clubmembers/play-interest/:clubId` mit Query `seasonId`, `lineupHalf` – geladene **Interessen** für Pool (`loadPlanningInterestedMemberIds`)
|
||||
- [ ] `POST /api/clubmembers/play-interest/:clubId` – Body `memberId`, `seasonId`, `lineupHalf`, `interested` – Mitglied als „interessiert“ markieren (`onPlanningMarkMemberInterested`)
|
||||
- [x] `GET /api/clubmembers/get/:clubId/:showAll` – bereits `MembersApi.listMembers`
|
||||
- [x] `GET /api/club-teams/...` Mannschaften Saison, `GET …/lineup?half=`, `PUT …/lineup` – `ClubTeamsApi` (Planung lädt Lineups **aller** Teams der Saison parallel wie Web `loadPlanningAssignments`)
|
||||
- [x] `PUT /api/club-teams/:id` – Stammdaten Planungsteam (`updateClubTeam`)
|
||||
- [x] `POST /api/club-teams/club/:clubId` – neue Planungs-Mannschaft (`createClubTeam` o. ä.)
|
||||
- [x] `DELETE /api/club-teams/:id` – Planungs-Mannschaft löschen
|
||||
|
||||
**Shared / Domäne:**
|
||||
|
||||
- [ ] DTOs für Play-Interest (Response-Zeilen: `memberId`, `interested`, …) + `MembersApi` Erweiterung
|
||||
- [ ] Optional: **PlanningState**-Use-Case (normalisierte Zuordnungen `teamId`/`memberId`/`position`, Halbserie, Recompute bei AK/GK-Wechsel) – Web: `normalizePlanningAssignments`, `removeAllIneligiblePlanningAssignments`, `isEligibleForPlanningTeam` (analog `TeamEditorLineupLogic.isEligibleForTeam` + Team-spezifische AK/GK aus `planningLocalTeams`)
|
||||
|
||||
**Android UI (Vorschlag für Umsetzungsschritte):**
|
||||
|
||||
1. [ ] **Einstieg:** In `TeamManagementScreen` (oder `AppRoot`) zweiter Modus **„Planung“** neben **„Mannschaften“** (wie Web-Workspace-Buttons), gleiche Saison-Auswahl wie Liste
|
||||
2. [ ] **Screen** `TeamPlanningScreen.kt` (oder modular `TeamPlanningPool.kt` / `TeamPlanningTeamCard.kt`): Pool + Liste der Teams; kein 1:1-Spiegel der Web-Grid-Optik nötig, aber **alle Aktionen** abdeckbar
|
||||
3. [ ] **Drag-and-Drop:** Entweder Compose **Drag-and-Drop** (Plattform-API) oder **Fallback** ohne DnD: „Mitglied auswählen → Ziel-Mannschaft“ / „Aus Mannschaft entfernen“ / Reihenfolge **↑↓** in der Lane (wie Aufstellungs-Tab)
|
||||
4. [ ] **Halbserie** (VR/RR) gemeinsam mit Web-Parameter `lineupHalf` an Play-Interest und Lineup-Requests
|
||||
5. [ ] **„In Workspace öffnen“** / Team-Editor: Web `convertPlanningTeamToRegular` prüfen – ggf. nur Navigation zum bestehenden `TeamEditorScreen` statt Konvertierungs-API
|
||||
6. [ ] **Autosave:** Web nutzt Debounce (`schedulePlanningTeamAutosave`) – mobil entweder gleichziehen oder explizit **„Speichern“** pro Team + Konflikt-Hinweis (Produktentscheidung)
|
||||
7. [ ] **i18n:** Keys aus `teamManagement.planning*` / `markAsInterested` usw. in `MobileStrings` oder Fallback-Kette wie andere Admin-Screens
|
||||
8. [ ] **Tests:** Serialisierung Play-Interest; optional Snapshot der Normalisierung `planningAssignments`
|
||||
|
||||
**Hinweis im Code:** Solange 8a offen ist, kann im Tab Aufstellung ein kurzer Verweis auf die **zukünftige** in-App-Planung stehen (nach Umsetzung Text anpassen oder entfernen).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 – Vereins- & Stammdaten-Einstellungen
|
||||
|
||||
@@ -30,6 +30,7 @@ import de.tt_tagebuch.shared.api.OfficialTournamentsApi
|
||||
import de.tt_tagebuch.shared.api.PermissionsApi
|
||||
import de.tt_tagebuch.shared.api.SeasonsApi
|
||||
import de.tt_tagebuch.shared.api.SessionApi
|
||||
import de.tt_tagebuch.shared.api.TeamDocumentsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingGroupsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingStatsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingTimesApi
|
||||
@@ -101,11 +102,13 @@ class AppDependencies(context: Context) {
|
||||
|
||||
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
|
||||
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
|
||||
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
|
||||
val apiLogsApi = ApiLogsApi(client)
|
||||
val apiLogsManager = ApiLogsManager(apiLogsApi)
|
||||
val tournamentsApi = TournamentsApi(client)
|
||||
val matchesApi = MatchesApi(client)
|
||||
val clubTeamsApi = ClubTeamsApi(client)
|
||||
val seasonsApi = SeasonsApi(client)
|
||||
val teamDocumentsApi = TeamDocumentsApi(client)
|
||||
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
|
||||
val officialTournamentsApi = OfficialTournamentsApi(client)
|
||||
val officialTournamentsReadManager = OfficialTournamentsReadManager(officialTournamentsApi)
|
||||
@@ -128,8 +131,9 @@ class AppDependencies(context: Context) {
|
||||
MemberGroupPhotosApi(client),
|
||||
)
|
||||
private val trainingStatsApi = TrainingStatsApi(client)
|
||||
val membersApi = MembersApi(client)
|
||||
val membersManager = MembersManager(
|
||||
MembersApi(client),
|
||||
membersApi,
|
||||
TrainingGroupsApi(client),
|
||||
MemberActivitiesApi(client),
|
||||
TrainingTimesApi(client),
|
||||
|
||||
@@ -86,7 +86,7 @@ internal fun ClubStammdatenFlowScreen(
|
||||
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
|
||||
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
|
||||
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
|
||||
ClubStammdatenSection.Teams -> MobileTeamsScreen(dependencies, onBack)
|
||||
ClubStammdatenSection.Teams -> TeamManagementScreen(dependencies, onBack)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,59 +460,6 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MobileTeamsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val perms = clubState.currentPermissions
|
||||
val scheduleState by dependencies.scheduleManager.state.collectAsState()
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.scheduleManager.loadClubTeams(clubId)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(StammdatenPad),
|
||||
) {
|
||||
StammdatenTopBar(tr("mobile.teamManagement", "Team-Verwaltung"), onBack)
|
||||
if (perms?.canReadMembers() != true) {
|
||||
Text(tr("mobile.noAccess", "Keine Berechtigung."))
|
||||
return@Column
|
||||
}
|
||||
scheduleState.error?.let { Text(it, color = MaterialTheme.colors.error) }
|
||||
if (scheduleState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
return@Column
|
||||
}
|
||||
if (scheduleState.teams.isEmpty()) {
|
||||
Text(tr("mobile.noTeams", "Keine Mannschaften gefunden."))
|
||||
return@Column
|
||||
}
|
||||
Text(
|
||||
tr("mobile.teamsHint", "Mannschaften werden aus Click-TT/nuLiga importiert und im Terminplan genutzt."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
scheduleState.teams.forEach { t ->
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(t.name ?: tr("mobile.team", "Mannschaft"), fontWeight = FontWeight.SemiBold)
|
||||
val league = t.league?.name ?: ""
|
||||
if (league.isNotBlank()) {
|
||||
Text(league, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
/**
|
||||
* Entspricht der Aufstellungs-/Meldungslogik in `TeamManagementView.vue`.
|
||||
*/
|
||||
internal object TeamEditorLineupLogic {
|
||||
|
||||
fun seasonReferenceYearFromLabel(seasonLabel: String): Int {
|
||||
val s = seasonLabel.trim()
|
||||
Regex("(\\d{4})\\s*/\\s*(\\d{4})").find(s)?.let {
|
||||
return it.groupValues[2].toInt()
|
||||
}
|
||||
Regex("(\\d{4})\\s*/\\s*(\\d{2})").find(s)?.let {
|
||||
val startYear = it.groupValues[1].toInt()
|
||||
val endTwo = it.groupValues[2].toInt()
|
||||
val century = (startYear / 100) * 100
|
||||
return century + endTwo
|
||||
}
|
||||
Regex("(\\d{4})").find(s)?.let { return it.groupValues[1].toInt() }
|
||||
return GregorianCalendar().get(Calendar.YEAR)
|
||||
}
|
||||
|
||||
fun parseBirthCalendar(value: String?): Calendar? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
val trimmed = value.trim()
|
||||
Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})$").find(trimmed)?.let {
|
||||
return GregorianCalendar(
|
||||
it.groupValues[3].toInt(),
|
||||
it.groupValues[2].toInt() - 1,
|
||||
it.groupValues[1].toInt(),
|
||||
)
|
||||
}
|
||||
Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(trimmed)?.let {
|
||||
return GregorianCalendar(
|
||||
it.groupValues[1].toInt(),
|
||||
it.groupValues[2].toInt() - 1,
|
||||
it.groupValues[3].toInt(),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun memberActualAge(member: Member, today: Calendar = GregorianCalendar()): Int? {
|
||||
val birth = parseBirthCalendar(member.birthDate) ?: return null
|
||||
var age = today.get(Calendar.YEAR) - birth.get(Calendar.YEAR)
|
||||
val hadBirthday =
|
||||
today.get(Calendar.MONTH) > birth.get(Calendar.MONTH) ||
|
||||
(today.get(Calendar.MONTH) == birth.get(Calendar.MONTH) &&
|
||||
today.get(Calendar.DAY_OF_MONTH) >= birth.get(Calendar.DAY_OF_MONTH))
|
||||
if (!hadBirthday) age -= 1
|
||||
return age
|
||||
}
|
||||
|
||||
fun memberSeasonAge(member: Member, seasonLabel: String): Int? {
|
||||
val birth = parseBirthCalendar(member.birthDate) ?: return null
|
||||
val refYear = seasonReferenceYearFromLabel(seasonLabel)
|
||||
return refYear - birth.get(Calendar.YEAR)
|
||||
}
|
||||
|
||||
fun memberAgeGroupCode(member: Member, seasonLabel: String): String {
|
||||
val seasonAge = memberSeasonAge(member, seasonLabel) ?: return "unknown"
|
||||
return when {
|
||||
seasonAge <= 11 -> "J11"
|
||||
seasonAge <= 13 -> "J13"
|
||||
seasonAge <= 15 -> "J15"
|
||||
seasonAge <= 17 -> "J17"
|
||||
seasonAge <= 19 -> "J19"
|
||||
else -> "adult"
|
||||
}
|
||||
}
|
||||
|
||||
fun parseLeagueAgeGroupCode(leagueName: String?): String {
|
||||
val source = leagueName.orEmpty()
|
||||
Regex("([JM])(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
|
||||
return "J${it.groupValues[2]}"
|
||||
}
|
||||
Regex("(?:jugend|mädchen)\\s*(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
|
||||
return "J${it.groupValues[1]}"
|
||||
}
|
||||
return "adult"
|
||||
}
|
||||
|
||||
fun parseLeagueGenderCode(leagueName: String?): String {
|
||||
val s = leagueName.orEmpty().lowercase()
|
||||
return if (Regex("(frauen|damen|mädchen|girls|women)").containsMatchIn(s)) "female" else "open"
|
||||
}
|
||||
|
||||
fun configuredTeamAgeGroup(teamAgeGroup: String?, leagueName: String?): String =
|
||||
teamAgeGroup?.takeIf { it.isNotBlank() } ?: parseLeagueAgeGroupCode(leagueName)
|
||||
|
||||
fun configuredTeamGender(teamGender: String?, leagueName: String?): String =
|
||||
teamGender?.takeIf { it.isNotBlank() } ?: parseLeagueGenderCode(leagueName)
|
||||
|
||||
fun isFemaleEligible(member: Member, teamGender: String): Boolean {
|
||||
if (teamGender != "female") return true
|
||||
return member.gender == "female"
|
||||
}
|
||||
|
||||
fun isEligibleForTeam(member: Member, teamAgeGroup: String, teamGender: String, seasonLabel: String): Boolean {
|
||||
if (!member.active || !isFemaleEligible(member, teamGender)) return false
|
||||
val memberAgeGroup = memberAgeGroupCode(member, seasonLabel)
|
||||
if (memberAgeGroup == "unknown") return false
|
||||
if (teamAgeGroup == "adult") {
|
||||
val actual = memberActualAge(member)
|
||||
if (actual != null && actual >= 18) return true
|
||||
return member.adultReleaseApproved == true || member.adultReserveApproved == true
|
||||
}
|
||||
val order = listOf("J11", "J13", "J15", "J17", "J19")
|
||||
val mi = order.indexOf(memberAgeGroup)
|
||||
val ti = order.indexOf(teamAgeGroup)
|
||||
if (mi < 0 || ti < 0) return false
|
||||
return mi <= ti
|
||||
}
|
||||
|
||||
fun lineupRatingValue(member: Member): Double {
|
||||
val q = member.qttr ?: return Double.NEGATIVE_INFINITY
|
||||
if (q <= 0) return Double.NEGATIVE_INFINITY
|
||||
return q.toDouble()
|
||||
}
|
||||
|
||||
fun lineupRatingLabel(member: Member): String {
|
||||
val q = member.qttr ?: return "–"
|
||||
if (q <= 0) return "–"
|
||||
return q.toString()
|
||||
}
|
||||
|
||||
fun eligibilityLabel(member: Member, teamAgeGroup: String, seasonLabel: String): String {
|
||||
val mag = memberAgeGroupCode(member, seasonLabel)
|
||||
if (teamAgeGroup != "adult" || mag == "adult") return "OK"
|
||||
if (member.adultReleaseApproved == true && member.adultReserveApproved == true) {
|
||||
return "Erw.+Reserve"
|
||||
}
|
||||
if (member.adultReleaseApproved == true) return "Erw.Freigabe"
|
||||
if (member.adultReserveApproved == true) return "Reserve"
|
||||
return "OK"
|
||||
}
|
||||
|
||||
data class LineupGroup(
|
||||
val code: String,
|
||||
val label: String,
|
||||
val members: List<MemberDisplay>,
|
||||
)
|
||||
|
||||
data class MemberDisplay(
|
||||
val member: Member,
|
||||
val lineupRatingLabel: String,
|
||||
val memberAgeGroupLabel: String,
|
||||
val eligibilityLabel: String,
|
||||
)
|
||||
|
||||
fun buildLineupGroups(
|
||||
members: List<Member>,
|
||||
teamAgeGroup: String,
|
||||
teamGender: String,
|
||||
seasonLabel: String,
|
||||
labelAdults: String,
|
||||
labelUnknown: String,
|
||||
): List<LineupGroup> {
|
||||
val eligible = members.filter { isEligibleForTeam(it, teamAgeGroup, teamGender, seasonLabel) }
|
||||
if (eligible.isEmpty()) return emptyList()
|
||||
val preferred = teamAgeGroup
|
||||
val defaultOrder =
|
||||
if (teamAgeGroup == "adult") {
|
||||
listOf("adult", "J19", "J17", "J15", "J13", "J11", "unknown")
|
||||
} else {
|
||||
listOf(teamAgeGroup) + listOf("J19", "J17", "J15", "J13", "J11").filter { it != teamAgeGroup } + "unknown"
|
||||
}
|
||||
val groupOrder =
|
||||
if (defaultOrder.contains(preferred)) {
|
||||
listOf(preferred) + defaultOrder.filter { it != preferred }
|
||||
} else {
|
||||
defaultOrder
|
||||
}
|
||||
|
||||
fun ageLabel(code: String) = when (code) {
|
||||
"adult" -> labelAdults
|
||||
"unknown" -> labelUnknown
|
||||
else -> code
|
||||
}
|
||||
|
||||
val byCode = linkedMapOf<String, MutableList<MemberDisplay>>()
|
||||
for (m in eligible) {
|
||||
val code = memberAgeGroupCode(m, seasonLabel)
|
||||
val row = MemberDisplay(
|
||||
member = m,
|
||||
lineupRatingLabel = lineupRatingLabel(m),
|
||||
memberAgeGroupLabel = ageLabel(code),
|
||||
eligibilityLabel = eligibilityLabel(m, teamAgeGroup, seasonLabel),
|
||||
)
|
||||
byCode.getOrPut(code) { mutableListOf() }.add(row)
|
||||
}
|
||||
for (list in byCode.values) {
|
||||
list.sortWith(
|
||||
compareByDescending<MemberDisplay> { lineupRatingValue(it.member) }
|
||||
.thenBy { it.member.lastName.lowercase() }
|
||||
.thenBy { it.member.firstName.lowercase() },
|
||||
)
|
||||
}
|
||||
return groupOrder.mapNotNull { code ->
|
||||
val rows = byCode[code] ?: return@mapNotNull null
|
||||
if (rows.isEmpty()) return@mapNotNull null
|
||||
LineupGroup(code = code, label = ageLabel(code), members = rows)
|
||||
}
|
||||
}
|
||||
|
||||
fun lineupGapViolationMessage(selectedOrdered: List<Member>, template: (String, String) -> String): String? {
|
||||
for (i in 0 until selectedOrdered.size - 1) {
|
||||
val a = selectedOrdered[i]
|
||||
val b = selectedOrdered[i + 1]
|
||||
val ra = lineupRatingValue(a)
|
||||
val rb = lineupRatingValue(b)
|
||||
if (!ra.isFinite() || !rb.isFinite()) continue
|
||||
if (rb > ra + 30) {
|
||||
return template("${b.firstName} ${b.lastName}".trim(), "${a.firstName} ${a.lastName}".trim())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,16 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
@@ -43,20 +41,17 @@ import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.shared.api.ScheduleLogic
|
||||
import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody
|
||||
import de.tt_tagebuch.shared.api.models.SeasonDto
|
||||
import de.tt_tagebuch.shared.api.models.canReadTeams
|
||||
import de.tt_tagebuch.shared.api.models.canWriteTeams
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val TeamPad = 20.dp
|
||||
private val TeamAgeOptions = listOf("adult", "J19", "J17", "J15", "J13", "J11")
|
||||
private val TeamGenderOptions = listOf("open", "female")
|
||||
|
||||
@Composable
|
||||
internal fun TeamManagementScreen(
|
||||
@@ -64,7 +59,9 @@ internal fun TeamManagementScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val getMobileString: (String, String) -> String = remember(languageCode) {
|
||||
{ k: String, fb: String -> MobileStrings.get(languageCode, k, fb) }
|
||||
}
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val perms = clubState.currentPermissions
|
||||
@@ -72,90 +69,73 @@ internal fun TeamManagementScreen(
|
||||
val canWrite = perms?.canWriteTeams() == true
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var seasons by remember { mutableStateOf<List<SeasonDto>>(emptyList()) }
|
||||
var selectedSeasonId by remember { mutableStateOf<Int?>(null) }
|
||||
var teams by remember { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
|
||||
var leagues by remember { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var search by remember { mutableStateOf("") }
|
||||
var seasons by remember(clubId) { mutableStateOf<List<SeasonDto>>(emptyList()) }
|
||||
var selectedSeasonId by remember(clubId) { mutableStateOf<Int?>(null) }
|
||||
var teams by remember(clubId) { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
|
||||
var leagues by remember(clubId) { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
|
||||
var loading by remember(clubId) { mutableStateOf(true) }
|
||||
var error by remember(clubId) { mutableStateOf<String?>(null) }
|
||||
var search by remember(clubId) { mutableStateOf("") }
|
||||
var loadTick by remember(clubId) { mutableStateOf(0) }
|
||||
|
||||
var showForm by remember { mutableStateOf(false) }
|
||||
var formIsNew by remember { mutableStateOf(true) }
|
||||
var formTeamId by remember { mutableStateOf<Int?>(null) }
|
||||
var formName by remember { mutableStateOf("") }
|
||||
var formLeagueId by remember { mutableStateOf<Int?>(null) }
|
||||
var formPlannedLeague by remember { mutableStateOf("") }
|
||||
var formGender by remember { mutableStateOf("open") }
|
||||
var formAge by remember { mutableStateOf("adult") }
|
||||
var formBusy by remember { mutableStateOf(false) }
|
||||
var editorOpen by remember { mutableStateOf(false) }
|
||||
var editorTeamId by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
var seasonMenu by remember { mutableStateOf(false) }
|
||||
var leagueMenu by remember { mutableStateOf(false) }
|
||||
var genderMenu by remember { mutableStateOf(false) }
|
||||
var ageMenu by remember { mutableStateOf(false) }
|
||||
|
||||
var deleteTarget by remember { mutableStateOf<ClubTeamDto?>(null) }
|
||||
var infoMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun resetFormForNew() {
|
||||
formIsNew = true
|
||||
formTeamId = null
|
||||
formName = ""
|
||||
formLeagueId = null
|
||||
formPlannedLeague = ""
|
||||
formGender = "open"
|
||||
formAge = "adult"
|
||||
fun openEditor(team: ClubTeamDto) {
|
||||
editorTeamId = team.id
|
||||
editorOpen = true
|
||||
}
|
||||
|
||||
fun openEdit(team: ClubTeamDto) {
|
||||
formIsNew = false
|
||||
formTeamId = team.id
|
||||
formName = team.name
|
||||
formLeagueId = team.leagueId
|
||||
formPlannedLeague = team.plannedLeagueName.orEmpty()
|
||||
formGender = team.teamGender?.takeIf { it.isNotBlank() } ?: "open"
|
||||
formAge = team.teamAgeGroup?.takeIf { it.isNotBlank() } ?: "adult"
|
||||
showForm = true
|
||||
}
|
||||
|
||||
suspend fun reloadData() {
|
||||
loading = true
|
||||
error = null
|
||||
runCatching {
|
||||
val s = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
|
||||
seasons = s.sortedByDescending { it.id }
|
||||
if (selectedSeasonId == null) {
|
||||
val cur = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }
|
||||
selectedSeasonId = cur.id
|
||||
}
|
||||
val sid = selectedSeasonId
|
||||
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
|
||||
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
|
||||
teams = ScheduleLogic.sortClubTeams(t)
|
||||
leagues = lg.sortedBy { it.name.lowercase() }
|
||||
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
|
||||
loading = false
|
||||
fun openNewTeamEditor() {
|
||||
editorTeamId = null
|
||||
editorOpen = true
|
||||
}
|
||||
|
||||
// Saisons + Default-Saison ohne selectedSeasonId in denselben Keys wie den Team-Load zu mischen:
|
||||
// Sonst setzt der Effekt selectedSeasonId, wird abgebrochen, runCatching zeigt die Cancellation-Meldung.
|
||||
LaunchedEffect(clubId) {
|
||||
selectedSeasonId = null
|
||||
seasons = emptyList()
|
||||
reloadData()
|
||||
try {
|
||||
val allSeasons = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
|
||||
.sortedByDescending { it.id }
|
||||
seasons = allSeasons
|
||||
if (allSeasons.isEmpty()) {
|
||||
selectedSeasonId = null
|
||||
teams = emptyList()
|
||||
leagues = emptyList()
|
||||
loading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (selectedSeasonId == null) {
|
||||
selectedSeasonId = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }.id
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId, selectedSeasonId) {
|
||||
if (selectedSeasonId == null) return@LaunchedEffect
|
||||
LaunchedEffect(clubId, selectedSeasonId, loadTick) {
|
||||
val sid = selectedSeasonId ?: return@LaunchedEffect
|
||||
loading = true
|
||||
error = null
|
||||
runCatching {
|
||||
val sid = selectedSeasonId
|
||||
try {
|
||||
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
|
||||
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
|
||||
teams = ScheduleLogic.sortClubTeams(t)
|
||||
leagues = lg.sortedBy { it.name.lowercase() }
|
||||
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
|
||||
loading = false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val filtered = remember(teams, search) {
|
||||
@@ -168,49 +148,59 @@ internal fun TeamManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(TeamPad)) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val editorSeasonId = selectedSeasonId
|
||||
if (editorOpen && editorSeasonId != null) {
|
||||
TeamEditorScreen(
|
||||
dependencies = dependencies,
|
||||
clubId = clubId,
|
||||
seasonId = editorSeasonId,
|
||||
seasonLabel = seasons.find { it.id == editorSeasonId }?.season.orEmpty(),
|
||||
initialTeamId = editorTeamId,
|
||||
onBack = { editorOpen = false },
|
||||
onSaved = { loadTick++ },
|
||||
)
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(TeamPad)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
Text(
|
||||
tr("navigation.teamManagement", "Team-Verwaltung"),
|
||||
getMobileString("navigation.teamManagement", "Team-Verwaltung"),
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (canWrite) {
|
||||
Button(
|
||||
onClick = {
|
||||
resetFormForNew()
|
||||
showForm = true
|
||||
},
|
||||
onClick = { openNewTeamEditor() },
|
||||
enabled = !loading && selectedSeasonId != null,
|
||||
) {
|
||||
Text(tr("mobile.teamNew", "Neue Mannschaft"))
|
||||
Text(getMobileString("mobile.teamNew", "Neue Mannschaft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
Text(tr("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
|
||||
Text(getMobileString("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
|
||||
return@Column
|
||||
}
|
||||
|
||||
Text(
|
||||
tr("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."),
|
||||
getMobileString("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(tr("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2)
|
||||
Text(getMobileString("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2)
|
||||
Box {
|
||||
OutlinedButton(onClick = { seasonMenu = true }, enabled = seasons.isNotEmpty()) {
|
||||
Text(
|
||||
seasons.find { it.id == selectedSeasonId }?.season
|
||||
?: tr("mobile.seasonPick", "Saison wählen"),
|
||||
?: getMobileString("mobile.seasonPick", "Saison wählen"),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = seasonMenu, onDismissRequest = { seasonMenu = false }) {
|
||||
@@ -232,7 +222,7 @@ internal fun TeamManagementScreen(
|
||||
onValueChange = { search = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(tr("mobile.teamSearch", "Mannschaft suchen")) },
|
||||
label = { Text(getMobileString("mobile.teamSearch", "Mannschaft suchen")) },
|
||||
)
|
||||
|
||||
error?.let {
|
||||
@@ -241,18 +231,22 @@ internal fun TeamManagementScreen(
|
||||
}
|
||||
|
||||
if (loading && teams.isEmpty()) {
|
||||
CircularProgressIndicator(Modifier.padding(top = 24.dp).align(Alignment.CenterHorizontally))
|
||||
Box(Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (!loading && teams.isEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(tr("mobile.noTeams", "Keine Mannschaften für diese Saison."))
|
||||
Text(getMobileString("mobile.noTeams", "Keine Mannschaften für diese Saison."))
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(filtered, key = { it.id }) { team ->
|
||||
@@ -260,21 +254,23 @@ internal fun TeamManagementScreen(
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(team.name.ifBlank { "#${team.id}" }, fontWeight = FontWeight.SemiBold)
|
||||
val lg = team.league?.name?.takeIf { it.isNotBlank() }
|
||||
?: tr("mobile.noLeague", "Keine Liga")
|
||||
?: getMobileString("mobile.noLeague", "Keine Liga")
|
||||
Text(lg, style = MaterialTheme.typography.caption)
|
||||
team.season?.season?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
team.plannedLeagueName?.takeIf { it.isNotBlank() }?.let { pl ->
|
||||
Text(
|
||||
tr("mobile.plannedLeagueShort", "Geplant: ") + pl,
|
||||
getMobileString("mobile.plannedLeagueShort", "Geplant: ") + pl,
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
}
|
||||
val g = team.teamGender ?: "open"
|
||||
val a = team.teamAgeGroup ?: "adult"
|
||||
val genShort = getMobileString("mobile.teamGenderShort", "Geschlecht")
|
||||
val ageShort = getMobileString("mobile.teamAgeShort", "AK")
|
||||
Text(
|
||||
"${tr("mobile.teamGenderShort", "Geschlecht")}: ${labelGender(tr, g)} · ${tr("mobile.teamAgeShort", "AK")}: ${labelAge(tr, a)}",
|
||||
"$genShort: ${labelGender(getMobileString, g)} · $ageShort: ${labelAge(getMobileString, a)}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
team.myTischtennisTeamId?.takeIf { it.isNotBlank() }?.let { mid ->
|
||||
@@ -282,11 +278,11 @@ internal fun TeamManagementScreen(
|
||||
}
|
||||
if (canWrite) {
|
||||
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { openEdit(team) }) {
|
||||
Text(tr("common.edit", "Bearbeiten"))
|
||||
TextButton(onClick = { openEditor(team) }) {
|
||||
Text(getMobileString("common.edit", "Bearbeiten"))
|
||||
}
|
||||
TextButton(onClick = { deleteTarget = team }) {
|
||||
Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error)
|
||||
Text(getMobileString("common.delete", "Löschen"), color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,193 +290,59 @@ internal fun TeamManagementScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showForm && selectedSeasonId != null) {
|
||||
val sid = selectedSeasonId!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!formBusy) showForm = false },
|
||||
title = {
|
||||
Text(
|
||||
if (formIsNew) tr("mobile.teamNew", "Neue Mannschaft")
|
||||
else tr("mobile.teamEdit", "Mannschaft bearbeiten"),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = formName,
|
||||
onValueChange = { formName = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(tr("mobile.teamName", "Name")) },
|
||||
)
|
||||
Text(tr("mobile.league", "Liga"), style = MaterialTheme.typography.caption)
|
||||
Box {
|
||||
OutlinedButton(onClick = { leagueMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
formLeagueId?.let { id -> leagues.find { it.id == id }?.name }
|
||||
?: tr("mobile.noLeague", "Keine Liga"),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = leagueMenu, onDismissRequest = { leagueMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
formLeagueId = null
|
||||
leagueMenu = false
|
||||
},
|
||||
) { Text(tr("mobile.noLeague", "Keine Liga")) }
|
||||
leagues.forEach { lg ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
formLeagueId = lg.id
|
||||
leagueMenu = false
|
||||
},
|
||||
) { Text(lg.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = formPlannedLeague,
|
||||
onValueChange = { formPlannedLeague = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(tr("mobile.plannedLeague", "Geplante Liga")) },
|
||||
)
|
||||
Text(tr("mobile.teamGenderShort", "Geschlecht"), style = MaterialTheme.typography.caption)
|
||||
Box {
|
||||
OutlinedButton(onClick = { genderMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(labelGender(tr, formGender))
|
||||
}
|
||||
DropdownMenu(expanded = genderMenu, onDismissRequest = { genderMenu = false }) {
|
||||
TeamGenderOptions.forEach { g ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
formGender = g
|
||||
genderMenu = false
|
||||
},
|
||||
) { Text(labelGender(tr, g)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(tr("mobile.teamAgeShort", "Altersklasse"), style = MaterialTheme.typography.caption)
|
||||
Box {
|
||||
OutlinedButton(onClick = { ageMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(labelAge(tr, formAge))
|
||||
}
|
||||
DropdownMenu(expanded = ageMenu, onDismissRequest = { ageMenu = false }) {
|
||||
TeamAgeOptions.forEach { a ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
formAge = a
|
||||
ageMenu = false
|
||||
},
|
||||
) { Text(labelAge(tr, a)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !formBusy && formName.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
formBusy = true
|
||||
runCatching {
|
||||
if (formIsNew) {
|
||||
deleteTarget?.let { target ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text(getMobileString("mobile.teamDeleteTitle", "Mannschaft löschen?")) },
|
||||
text = { Text(target.name.ifBlank { "#${target.id}" }) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val id = target.id
|
||||
deleteTarget = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) }
|
||||
withContext(Dispatchers.IO) {
|
||||
dependencies.clubTeamsApi.createClubTeam(
|
||||
clubId,
|
||||
ClubTeamCreateBody(
|
||||
name = formName.trim(),
|
||||
leagueId = formLeagueId,
|
||||
seasonId = sid,
|
||||
teamGender = formGender,
|
||||
teamAgeGroup = formAge,
|
||||
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val id = formTeamId ?: return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
dependencies.clubTeamsApi.updateClubTeam(
|
||||
id,
|
||||
ClubTeamUpdateBody(
|
||||
name = formName.trim(),
|
||||
leagueId = formLeagueId,
|
||||
seasonId = sid,
|
||||
teamGender = formGender,
|
||||
teamAgeGroup = formAge,
|
||||
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
|
||||
),
|
||||
)
|
||||
dependencies.scheduleManager.loadClubTeams(clubId)
|
||||
}
|
||||
loadTick++
|
||||
}.onFailure { ex ->
|
||||
if (ex is CancellationException) throw ex
|
||||
infoMessage = ex.message
|
||||
}
|
||||
reloadData()
|
||||
dependencies.scheduleManager.loadClubTeams(clubId)
|
||||
showForm = false
|
||||
}.onFailure {
|
||||
infoMessage = it.message ?: tr("common.error", "Fehler")
|
||||
}
|
||||
formBusy = false
|
||||
}
|
||||
},
|
||||
) { Text(tr("common.save", "Speichern")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { if (!formBusy) showForm = false }) {
|
||||
Text(tr("common.cancel", "Abbrechen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { Text(getMobileString("common.delete", "Löschen")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteTarget = null }) {
|
||||
Text(getMobileString("common.cancel", "Abbrechen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
deleteTarget?.let { target ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text(tr("mobile.teamDeleteTitle", "Mannschaft löschen?")) },
|
||||
text = { Text(target.name.ifBlank { "#${target.id}" }) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val id = target.id
|
||||
deleteTarget = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) }
|
||||
reloadData()
|
||||
dependencies.scheduleManager.loadClubTeams(clubId)
|
||||
}.onFailure { infoMessage = it.message }
|
||||
}
|
||||
},
|
||||
) { Text(tr("common.delete", "Löschen")) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } },
|
||||
)
|
||||
}
|
||||
|
||||
infoMessage?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { infoMessage = null },
|
||||
title = { Text(tr("common.error", "Fehler")) },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(tr("common.ok", "OK")) } },
|
||||
)
|
||||
infoMessage?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { infoMessage = null },
|
||||
title = { Text(getMobileString("common.error", "Fehler")) },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(getMobileString("common.ok", "OK")) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun labelGender(tr: (String, String) -> String, code: String): String =
|
||||
private fun labelGender(t: (String, String) -> String, code: String): String =
|
||||
when (code) {
|
||||
"female" -> tr("mobile.teamGenderFemale", "Weiblich")
|
||||
else -> tr("mobile.teamGenderOpen", "Offen")
|
||||
"male" -> t("mobile.teamGenderMale", "Männlich")
|
||||
"female" -> t("mobile.teamGenderFemale", "Weiblich")
|
||||
else -> t("mobile.teamGenderOpen", "Offen")
|
||||
}
|
||||
|
||||
private fun labelAge(tr: (String, String) -> String, code: String): String =
|
||||
if (code == "adult") tr("mobile.teamAgeAdult", "Erwachsene") else code
|
||||
private fun labelAge(t: (String, String) -> String, code: String): String =
|
||||
if (code == "adult") t("mobile.teamAgeAdult", "Erwachsene") else code
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogDetailEnvelopeDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogsListEnvelopeDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogsListPageDto
|
||||
import de.tt_tagebuch.shared.api.models.SchedulerLastExecutionsEnvelopeDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
@@ -35,4 +36,10 @@ class ApiLogsApi(
|
||||
val env = client.http.get("/api/logs/$id").body<ApiLogDetailEnvelopeDto>()
|
||||
return env.data
|
||||
}
|
||||
|
||||
suspend fun getSchedulerLastExecutions(clubId: Int? = null): SchedulerLastExecutionsEnvelopeDto {
|
||||
return client.http.get("/api/logs/scheduler/last-executions") {
|
||||
clubId?.let { parameter("clubId", it) }
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamLineupRowDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody
|
||||
import de.tt_tagebuch.shared.api.models.TeamLineupUpdateBody
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
@@ -47,4 +49,16 @@ class ClubTeamsApi(
|
||||
suspend fun deleteClubTeam(clubTeamId: Int) {
|
||||
client.http.delete("/api/club-teams/$clubTeamId")
|
||||
}
|
||||
|
||||
suspend fun getLineup(clubTeamId: Int, half: String): List<ClubTeamLineupRowDto> {
|
||||
return client.http.get("/api/club-teams/$clubTeamId/lineup") {
|
||||
parameter("half", half)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun updateLineup(clubTeamId: Int, body: TeamLineupUpdateBody): List<ClubTeamLineupRowDto> {
|
||||
return client.http.put("/api/club-teams/$clubTeamId/lineup") {
|
||||
setBody(body)
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.LeaguePlayerStatDto
|
||||
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
|
||||
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
|
||||
@@ -29,6 +30,12 @@ class MatchesApi(
|
||||
return client.http.get("/api/matches/leagues/$clubId/table/$leagueId").body()
|
||||
}
|
||||
|
||||
suspend fun getLeaguePlayerStats(clubId: Int, leagueId: Int, seasonId: Int? = null): List<LeaguePlayerStatDto> {
|
||||
return client.http.get("/api/matches/leagues/$clubId/stats/$leagueId") {
|
||||
seasonId?.let { parameter("seasonid", it) }
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun updateMatchPlayers(matchId: Int, body: UpdateMatchPlayersBody) {
|
||||
client.http.patch("/api/matches/$matchId/players") {
|
||||
setBody(body)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.MyTtConfigureLeagueBody
|
||||
import de.tt_tagebuch.shared.api.models.MyTtConfigureTeamBody
|
||||
import de.tt_tagebuch.shared.api.models.MyTtFetchJobEnvelopeDto
|
||||
import de.tt_tagebuch.shared.api.models.MyTtFetchJobStartDto
|
||||
import de.tt_tagebuch.shared.api.models.MyTtFetchTeamDataBody
|
||||
import de.tt_tagebuch.shared.api.models.MyTtParseUrlBody
|
||||
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountEnvelope
|
||||
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountSaveResponse
|
||||
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountUpsertBody
|
||||
@@ -46,4 +52,31 @@ class MyTischtennisApi(
|
||||
suspend fun deleteAccount() {
|
||||
client.http.delete("/api/mytischtennis/account")
|
||||
}
|
||||
|
||||
suspend fun parseUrl(url: String): JsonObject =
|
||||
client.http.post("/api/mytischtennis/parse-url") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(MyTtParseUrlBody(url))
|
||||
}.body()
|
||||
|
||||
suspend fun configureTeam(body: MyTtConfigureTeamBody): JsonObject =
|
||||
client.http.post("/api/mytischtennis/configure-team") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}.body()
|
||||
|
||||
suspend fun configureLeague(body: MyTtConfigureLeagueBody): JsonObject =
|
||||
client.http.post("/api/mytischtennis/configure-league") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}.body()
|
||||
|
||||
suspend fun startFetchTeamDataJob(clubTeamId: Int): MyTtFetchJobStartDto =
|
||||
client.http.post("/api/mytischtennis/fetch-team-data/async") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(MyTtFetchTeamDataBody(clubTeamId))
|
||||
}.body()
|
||||
|
||||
suspend fun getFetchTeamDataJob(jobId: String): MyTtFetchJobEnvelopeDto =
|
||||
client.http.get("/api/mytischtennis/fetch-team-data/jobs/$jobId").body()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.TeamDocumentDto
|
||||
import de.tt_tagebuch.shared.api.models.TeamDocumentParseEnvelopeDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.Headers
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
|
||||
class TeamDocumentsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listByClubTeam(clubTeamId: Int): List<TeamDocumentDto> {
|
||||
return client.http.get("/api/team-documents/club-team/$clubTeamId").body()
|
||||
}
|
||||
|
||||
suspend fun upload(
|
||||
clubTeamId: Int,
|
||||
documentType: String,
|
||||
bytes: ByteArray,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
): TeamDocumentDto {
|
||||
return client.http.post("/api/team-documents/club-team/$clubTeamId/upload") {
|
||||
contentType(ContentType.MultiPart.FormData)
|
||||
setBody(
|
||||
MultiPartFormDataContent(
|
||||
formData {
|
||||
append("documentType", documentType)
|
||||
append(
|
||||
"document",
|
||||
bytes,
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, mimeType)
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun parse(documentId: Int, leagueId: Int): TeamDocumentParseEnvelopeDto {
|
||||
return client.http.post("/api/team-documents/$documentId/parse") {
|
||||
parameter("leagueid", leagueId)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun download(documentId: Int): ByteArray {
|
||||
return client.http.get("/api/team-documents/$documentId/download").body()
|
||||
}
|
||||
|
||||
suspend fun delete(documentId: Int) {
|
||||
client.http.delete("/api/team-documents/$documentId")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class LeaguePlayerStatDto(
|
||||
val memberId: Int,
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val totalSeason: Int = 0,
|
||||
val totalFirstHalf: Int = 0,
|
||||
val totalSecondHalf: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubTeamLineupRowDto(
|
||||
val id: Int? = null,
|
||||
val clubTeamId: Int? = null,
|
||||
val memberId: Int,
|
||||
val lineupHalf: String? = null,
|
||||
val position: Int = 0,
|
||||
val member: Member? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamLineupAssignmentItem(
|
||||
val memberId: Int,
|
||||
val position: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamLineupUpdateBody(
|
||||
val assignments: List<TeamLineupAssignmentItem>,
|
||||
val lineupHalf: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamDocumentDto(
|
||||
val id: Int,
|
||||
val fileName: String = "",
|
||||
val originalFileName: String = "",
|
||||
val documentType: String = "",
|
||||
val clubTeamId: Int? = null,
|
||||
val createdAt: String? = null,
|
||||
val mimeType: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamDocumentParseResultDto(
|
||||
val matchesFound: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamDocumentSaveResultDto(
|
||||
val created: Int = 0,
|
||||
val updated: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TeamDocumentParseEnvelopeDto(
|
||||
val parseResult: TeamDocumentParseResultDto? = null,
|
||||
val saveResult: TeamDocumentSaveResultDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SchedulerLastExecutionsEnvelopeDto(
|
||||
val success: Boolean = false,
|
||||
val data: SchedulerLastExecutionsDataDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SchedulerLastExecutionsDataDto(
|
||||
@SerialName("rating_updates")
|
||||
val ratingUpdates: SchedulerJobBlockDto? = null,
|
||||
@SerialName("match_results")
|
||||
val matchResults: SchedulerJobBlockDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SchedulerJobBlockDto(
|
||||
val lastRun: String? = null,
|
||||
val success: Boolean? = null,
|
||||
val executionTime: Int? = null,
|
||||
val updatedCount: Int? = null,
|
||||
val fetchedCount: Int? = null,
|
||||
val errorMessage: String? = null,
|
||||
val teamDetails: List<SchedulerTeamDetailDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SchedulerTeamDetailDto(
|
||||
val clubTeamId: Int = 0,
|
||||
val teamName: String? = null,
|
||||
val success: Boolean = false,
|
||||
val lastRun: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyTtParseUrlBody(val url: String)
|
||||
|
||||
@Serializable
|
||||
data class MyTtConfigureTeamBody(
|
||||
val url: String,
|
||||
val clubTeamId: Int,
|
||||
val createLeague: Boolean? = null,
|
||||
val createSeason: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyTtConfigureLeagueBody(
|
||||
val url: String,
|
||||
val createSeason: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyTtFetchTeamDataBody(val clubTeamId: Int)
|
||||
|
||||
@Serializable
|
||||
data class MyTtFetchJobStartDto(
|
||||
val success: Boolean = false,
|
||||
val jobId: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyTtFetchJobEnvelopeDto(
|
||||
val success: Boolean = false,
|
||||
val job: MyTtFetchJobDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyTtFetchJobDto(
|
||||
val jobId: String? = null,
|
||||
val status: String? = null,
|
||||
val result: JsonObject? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
Reference in New Issue
Block a user