diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 1245e0e5..3177b0a2 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -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 diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 8be6f4ba..aa298831 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -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), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt index 3f7aec66..f3dcf4e7 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt @@ -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( diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorLineupLogic.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorLineupLogic.kt new file mode 100644 index 00000000..37c571f8 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorLineupLogic.kt @@ -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, + ) + + data class MemberDisplay( + val member: Member, + val lineupRatingLabel: String, + val memberAgeGroupLabel: String, + val eligibilityLabel: String, + ) + + fun buildLineupGroups( + members: List, + teamAgeGroup: String, + teamGender: String, + seasonLabel: String, + labelAdults: String, + labelUnknown: String, + ): List { + 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>() + 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 { 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, 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 + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorScreen.kt new file mode 100644 index 00000000..7fa17c2a --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamEditorScreen.kt @@ -0,0 +1,1289 @@ +package de.tt_tagebuch.app.ui + +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +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.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 +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Tab +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.app.pdf.shareFileWithMime +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.LeaguePlayerStatDto +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.TeamLineupAssignmentItem +import de.tt_tagebuch.shared.api.models.TeamLineupUpdateBody +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.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Calendar +import java.util.GregorianCalendar + +private enum class TeamEditorTab { + Basic, + Stats, + Lineup, + Documents, + Jobs, + MyTischtennis, +} + +private data class MyTtUiStatus( + val complete: Boolean, + val partial: Boolean, + val missingItemLabels: List, + val needsLeague: Boolean, + val needsTeamId: Boolean, +) + +private fun myTtStatus(team: ClubTeamDto, t: (String, String) -> String): MyTtUiStatus { + val hasTeamId = !team.myTischtennisTeamId.isNullOrBlank() + val hasLeague = team.league != null + val hasPlanned = !team.plannedLeagueName.isNullOrBlank() + val hasLeagueForWorkflow = hasLeague || hasPlanned + val lg = team.league + val hasLeagueConfig = hasLeague && + !lg?.myTischtennisGroupId.isNullOrBlank() && + !lg?.association.isNullOrBlank() && + !lg?.groupname.isNullOrBlank() + + val missing = mutableListOf() + if (!hasTeamId) missing.add(t("teamManagement.teamId", "MyTT-Team-ID")) + if (!hasLeagueForWorkflow) missing.add(t("teamManagement.league", "Liga")) + if (hasLeague && lg?.myTischtennisGroupId.isNullOrBlank()) { + missing.add(t("teamManagement.groupId", "Gruppen-ID")) + } + if (hasLeague && lg?.association.isNullOrBlank()) { + missing.add(t("teamManagement.association", "Verband")) + } + if (hasLeague && lg?.groupname.isNullOrBlank()) { + missing.add(t("teamManagement.groupName", "Gruppenname")) + } + + val complete = hasTeamId && hasLeagueConfig + val partial = (hasTeamId || hasLeagueConfig || hasPlanned) && !complete + return MyTtUiStatus( + complete = complete, + partial = partial, + missingItemLabels = missing, + needsLeague = !hasLeagueForWorkflow, + needsTeamId = !hasTeamId, + ) +} + +private fun isSecondHalfSeason(): Boolean { + val now = GregorianCalendar() + val seasonStart = GregorianCalendar(now.get(Calendar.YEAR), Calendar.JULY, 1, 0, 0, 0) + if (seasonStart.after(now)) { + seasonStart.add(Calendar.YEAR, -1) + } + val secondHalfStart = GregorianCalendar(seasonStart.get(Calendar.YEAR) + 1, Calendar.JANUARY, 1, 0, 0, 0) + return now.timeInMillis >= secondHalfStart.timeInMillis +} + +@Composable +internal fun TeamEditorScreen( + dependencies: AppDependencies, + clubId: Int, + seasonId: Int, + seasonLabel: String, + initialTeamId: Int?, + onBack: () -> Unit, + onSaved: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun t(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val context = LocalContext.current + val clubState by dependencies.clubManager.state.collectAsState() + val canWrite = clubState.currentPermissions?.canWriteTeams() == true + val scope = rememberCoroutineScope() + + val startedWithoutId = initialTeamId == null + var team by remember(initialTeamId) { mutableStateOf(null) } + var loadingTeam by remember(initialTeamId) { mutableStateOf(initialTeamId != null) } + var teamLoadError by remember { mutableStateOf(null) } + + var tabIndex by remember { mutableIntStateOf(0) } + val tabs = remember(team?.id, team?.leagueId) { + buildList { + add(TeamEditorTab.Basic) + if (team != null && (team!!.leagueId ?: 0) > 0) add(TeamEditorTab.Stats) + if (team != null) { + add(TeamEditorTab.Lineup) + add(TeamEditorTab.Documents) + add(TeamEditorTab.Jobs) + add(TeamEditorTab.MyTischtennis) + } + } + } + + LaunchedEffect(tabs.size) { + if (tabIndex >= tabs.size) tabIndex = 0 + } + + var pendingDocType by remember { mutableStateOf(null) } + + var formName by remember { mutableStateOf("") } + var formLeagueId by remember { mutableStateOf(null) } + var formPlanned by remember { mutableStateOf("") } + var formGender by remember { mutableStateOf("open") } + var formAge by remember { mutableStateOf("adult") } + var leagues by remember(clubId, seasonId) { mutableStateOf>(emptyList()) } + var leagueMenu by remember { mutableStateOf(false) } + var genderMenu by remember { mutableStateOf(false) } + var ageMenu by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var info by remember { mutableStateOf(null) } + + var playerStats by remember { mutableStateOf>(emptyList()) } + var loadingStats by remember { mutableStateOf(false) } + + var clubMembers by remember { mutableStateOf>(emptyList()) } + var lineupHalf by remember { mutableStateOf(if (isSecondHalfSeason()) "second_half" else "first_half") } + var lineupRows by remember { mutableStateOf>(emptyList()) } + var loadingLineup by remember { mutableStateOf(false) } + var savingLineup by remember { mutableStateOf(false) } + + var documents by remember { mutableStateOf>(emptyList()) } + var loadingDocs by remember { mutableStateOf(false) } + var parsingDocId by remember { mutableStateOf(null) } + + var schedulerLoaded by remember { mutableStateOf(false) } + var schedulerBlock by remember { + mutableStateOf( + null, + ) + } + + var myTtUrl by remember { mutableStateOf("") } + var myTtLeagueUrl by remember { mutableStateOf("") } + var myTtBusy by remember { mutableStateOf(false) } + var createLeague by remember { mutableStateOf(true) } + var createSeason by remember { mutableStateOf(true) } + var createSeasonLeague by remember { mutableStateOf(true) } + + fun effectiveTeam(): ClubTeamDto? = team + + fun reloadDocuments() { + val id = team?.id ?: return + scope.launch { + loadingDocs = true + runCatching { + documents = withContext(Dispatchers.IO) { + dependencies.teamDocumentsApi.listByClubTeam(id) + } + } + loadingDocs = false + } + } + + fun reloadLineup() { + val id = team?.id ?: return + scope.launch { + loadingLineup = true + runCatching { + lineupRows = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.getLineup(id, lineupHalf) + } + } + loadingLineup = false + } + } + + fun reloadMembers() { + scope.launch { + runCatching { + clubMembers = withContext(Dispatchers.IO) { + dependencies.membersApi.listMembers(clubId, showAll = true) + } + } + } + } + + LaunchedEffect(initialTeamId, clubId, seasonId) { + runCatching { + leagues = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.listLeagues(clubId, seasonId).sortedBy { it.name.lowercase() } + } + if (startedWithoutId) { + formName = "" + formLeagueId = null + formPlanned = "" + formGender = "open" + formAge = "adult" + loadingTeam = false + } else { + loadingTeam = true + teamLoadError = null + val loaded = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.getClubTeam(initialTeamId!!) + } + team = loaded + formName = loaded.name + formLeagueId = loaded.leagueId + formPlanned = loaded.plannedLeagueName.orEmpty() + formGender = loaded.teamGender?.takeIf { it.isNotBlank() } ?: "open" + formAge = loaded.teamAgeGroup?.takeIf { it.isNotBlank() } ?: "adult" + loadingTeam = false + } + }.onFailure { + teamLoadError = it.message ?: t("mobile.teamLoadError", "Laden fehlgeschlagen") + loadingTeam = false + } + } + + LaunchedEffect(team?.id, tabIndex, tabs) { + val currentTab = tabs.getOrNull(tabIndex) ?: return@LaunchedEffect + if (team == null) return@LaunchedEffect + when (currentTab) { + TeamEditorTab.Stats -> { + val lid = team!!.leagueId ?: return@LaunchedEffect + loadingStats = true + runCatching { + playerStats = withContext(Dispatchers.IO) { + dependencies.matchesApi.getLeaguePlayerStats(clubId, lid, seasonId) + } + } + loadingStats = false + } + TeamEditorTab.Lineup -> { + reloadMembers() + reloadLineup() + } + TeamEditorTab.Documents -> reloadDocuments() + TeamEditorTab.Jobs -> { + if (!schedulerLoaded) { + runCatching { + val env = withContext(Dispatchers.IO) { + dependencies.apiLogsApi.getSchedulerLastExecutions(clubId) + } + schedulerBlock = env.data?.matchResults + schedulerLoaded = true + } + } + } + else -> {} + } + } + + val documentPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + val id = team?.id ?: return@rememberLauncherForActivityResult + val type = pendingDocType ?: return@rememberLauncherForActivityResult + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + saving = true + runCatching { + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)!!.use { it.readBytes() } + } + val name = uri.lastPathSegment ?: "upload.bin" + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + withContext(Dispatchers.IO) { + dependencies.teamDocumentsApi.upload(id, type, bytes, name, mime) + } + reloadDocuments() + }.onFailure { + info = it.message ?: t("common.error", "Fehler") + } + saving = false + } + } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text( + if (team == null) t("mobile.teamNew", "Neue Mannschaft") else (team?.name ?: "…"), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (loadingTeam) { + CircularProgressIndicator(Modifier.padding(top = 24.dp)) + return@Column + } + teamLoadError?.let { + Text(it, color = MaterialTheme.colors.error) + return@Column + } + + ScrollableTabRow( + selectedTabIndex = tabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0)), + edgePadding = 0.dp, + modifier = Modifier.fillMaxWidth(), + ) { + tabs.forEachIndexed { i, tab -> + Tab( + selected = tabIndex == i, + onClick = { tabIndex = i }, + text = { + Text( + when (tab) { + TeamEditorTab.Basic -> t("mobile.teamEditorTabBasic", "Stammdaten") + TeamEditorTab.Stats -> t("mobile.teamEditorTabStats", "Spieler") + TeamEditorTab.Lineup -> t("mobile.teamEditorTabLineup", "Aufstellung") + TeamEditorTab.Documents -> t("mobile.teamEditorTabDocs", "Dokumente") + TeamEditorTab.Jobs -> t("mobile.teamEditorTabJobs", "Jobs") + TeamEditorTab.MyTischtennis -> "MyTT" + }, + ) + }, + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Box(Modifier.weight(1f).fillMaxWidth()) { + when (tabs.getOrNull(tabIndex)) { + TeamEditorTab.Basic -> BasicTab( + t = ::t, + canWrite = canWrite, + isCreateMode = team == null, + formName = formName, + onName = { formName = it }, + formLeagueId = formLeagueId, + leagues = leagues, + leagueMenu = leagueMenu, + onLeagueMenu = { leagueMenu = it }, + onLeaguePick = { formLeagueId = it; leagueMenu = false }, + formPlanned = formPlanned, + onPlanned = { formPlanned = it }, + formGender = formGender, + formAge = formAge, + genderMenu = genderMenu, + onGenderMenu = { genderMenu = it }, + ageMenu = ageMenu, + onAgeMenu = { ageMenu = it }, + onGenderPick = { formGender = it; genderMenu = false }, + onAgePick = { formAge = it; ageMenu = false }, + saving = saving, + team = team, + onSave = { + if (!canWrite || formName.isBlank()) return@BasicTab + scope.launch { + saving = true + runCatching { + if (team == null) { + val created = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.createClubTeam( + clubId, + ClubTeamCreateBody( + name = formName.trim(), + leagueId = formLeagueId, + seasonId = seasonId, + teamGender = formGender, + teamAgeGroup = formAge, + plannedLeagueName = formPlanned.trim().ifBlank { null }, + ), + ) + } + team = created + withContext(Dispatchers.IO) { + dependencies.scheduleManager.loadClubTeams(clubId) + } + onSaved() + tabIndex = 0 + } else { + val id = team!!.id + val updated = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.updateClubTeam( + id, + ClubTeamUpdateBody( + name = formName.trim(), + leagueId = formLeagueId, + seasonId = seasonId, + teamGender = formGender, + teamAgeGroup = formAge, + plannedLeagueName = formPlanned.trim().ifBlank { null }, + ), + ) + } + team = updated + withContext(Dispatchers.IO) { + dependencies.scheduleManager.loadClubTeams(clubId) + } + onSaved() + } + }.onFailure { ex -> + if (ex is CancellationException) throw ex + info = ex.message ?: t("common.error", "Fehler") + } + saving = false + } + }, + ) + + TeamEditorTab.Stats -> StatsTab( + t = ::t, + loading = loadingStats, + stats = playerStats, + secondHalf = isSecondHalfSeason(), + members = clubMembers, + onRefresh = { + val lid = team?.leagueId ?: return@StatsTab + scope.launch { + loadingStats = true + runCatching { + playerStats = withContext(Dispatchers.IO) { + dependencies.matchesApi.getLeaguePlayerStats(clubId, lid, seasonId) + } + } + loadingStats = false + } + }, + ) + + TeamEditorTab.Lineup -> LineupTab( + t = ::t, + context = context, + canWrite = canWrite, + team = team!!, + seasonLabel = seasonLabel, + members = clubMembers, + lineupHalf = lineupHalf, + onHalfChange = { lineupHalf = it; reloadLineup() }, + loading = loadingLineup, + saving = savingLineup, + lineupRows = lineupRows, + onPersist = { assignments -> + scope.launch { + savingLineup = true + runCatching { + lineupRows = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.updateLineup( + team!!.id, + TeamLineupUpdateBody(assignments = assignments, lineupHalf = lineupHalf), + ) + } + }.onFailure { ex -> + if (ex is CancellationException) throw ex + info = ex.message + } + savingLineup = false + } + }, + onReload = { + reloadMembers() + reloadLineup() + }, + ) + + TeamEditorTab.Documents -> DocumentsTab( + t = ::t, + canWrite = canWrite, + team = team!!, + documents = documents, + loading = loadingDocs, + parsingId = parsingDocId, + onPickUpload = { docType -> + pendingDocType = docType + documentPicker.launch(arrayOf("application/pdf", "text/*", "text/csv", "application/msword", "*/*")) + }, + onParse = { doc -> + val lid = team!!.leagueId + if (lid == null) { + info = t("teamManagement.missingLeagueForTeam", "Liga erforderlich zum Parsen.") + return@DocumentsTab + } + scope.launch { + parsingDocId = doc.id + runCatching { + withContext(Dispatchers.IO) { + dependencies.teamDocumentsApi.parse(doc.id, lid) + } + info = t("mobile.teamDocParseDone", "Dokument verarbeitet.") + }.onFailure { + info = it.message + } + parsingDocId = null + } + }, + onOpen = { doc -> + scope.launch { + runCatching { + val bytes = withContext(Dispatchers.IO) { + dependencies.teamDocumentsApi.download(doc.id) + } + val ext = when { + doc.mimeType?.contains("pdf") == true -> ".pdf" + doc.mimeType?.contains("text") == true -> ".txt" + else -> "" + } + val f = File(context.cacheDir, "team-doc-${doc.id}$ext") + f.writeBytes(bytes) + shareFileWithMime( + context, + f, + doc.mimeType ?: "application/octet-stream", + doc.originalFileName, + ) + }.onFailure { + info = it.message + } + } + }, + ) + + TeamEditorTab.Jobs -> JobsTab(t = ::t, block = schedulerBlock, teamId = team!!.id) + + TeamEditorTab.MyTischtennis -> MyTtTab( + t = ::t, + team = team!!, + busy = myTtBusy, + url = myTtUrl, + onUrl = { myTtUrl = it }, + leagueUrl = myTtLeagueUrl, + onLeagueUrl = { myTtLeagueUrl = it }, + createLeague = createLeague, + onCreateLeague = { createLeague = it }, + createSeason = createSeason, + onCreateSeason = { createSeason = it }, + createSeasonLeague = createSeasonLeague, + onCreateSeasonLeague = { createSeasonLeague = it }, + onParse = { + scope.launch { + myTtBusy = true + runCatching { + withContext(Dispatchers.IO) { + dependencies.myTischtennisApi.parseUrl(myTtUrl.trim()) + } + info = t("mobile.teamMyTtParseOk", "URL geparst.") + }.onFailure { info = it.message } + myTtBusy = false + } + }, + onConfigureTeam = { + scope.launch { + myTtBusy = true + runCatching { + withContext(Dispatchers.IO) { + dependencies.myTischtennisApi.configureTeam( + de.tt_tagebuch.shared.api.models.MyTtConfigureTeamBody( + url = myTtUrl.trim(), + clubTeamId = team!!.id, + createLeague = createLeague, + createSeason = createSeason, + ), + ) + } + team = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.getClubTeam(team!!.id) + } + onSaved() + info = t("mobile.teamMyTtConfigured", "Team verknüpft.") + }.onFailure { info = it.message } + myTtBusy = false + } + }, + onConfigureLeague = { + scope.launch { + myTtBusy = true + runCatching { + withContext(Dispatchers.IO) { + dependencies.myTischtennisApi.configureLeague( + de.tt_tagebuch.shared.api.models.MyTtConfigureLeagueBody( + url = myTtLeagueUrl.trim(), + createSeason = createSeasonLeague, + ), + ) + } + info = t("mobile.teamMyTtLeagueConfigured", "Liga verknüpft.") + }.onFailure { info = it.message } + myTtBusy = false + } + }, + onFetch = { + scope.launch { + myTtBusy = true + runCatching { + val start = withContext(Dispatchers.IO) { + dependencies.myTischtennisApi.startFetchTeamDataJob(team!!.id) + } + val jobId = start.jobId ?: error("jobId") + var done = false + repeat(120) { + delay(2000) + val st = withContext(Dispatchers.IO) { + dependencies.myTischtennisApi.getFetchTeamDataJob(jobId) + } + val status = st.job?.status ?: return@repeat + if (status == "completed" || status == "failed") { + done = true + if (status == "failed") { + info = st.job?.error ?: t("mobile.teamMyTtFetchFail", "Abruf fehlgeschlagen") + } else { + info = t("mobile.teamMyTtFetchOk", "Daten abgerufen.") + team = withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.getClubTeam(team!!.id) + } + onSaved() + } + return@launch + } + } + if (!done) { + info = t("mobile.teamMyTtFetchTimeout", "Zeitüberschreitung beim Abruf.") + } + }.onFailure { + if (it !is CancellationException) info = it.message + } + myTtBusy = false + } + }, + ) + + null -> {} + } + } + } + + info?.let { msg -> + AlertDialog( + onDismissRequest = { info = null }, + title = { Text(t("common.info", "Hinweis")) }, + text = { Text(msg) }, + confirmButton = { TextButton(onClick = { info = null }) { Text(t("common.ok", "OK")) } }, + ) + } +} + +@Composable +private fun BasicTab( + t: (String, String) -> String, + canWrite: Boolean, + isCreateMode: Boolean, + formName: String, + onName: (String) -> Unit, + formLeagueId: Int?, + leagues: List, + leagueMenu: Boolean, + onLeagueMenu: (Boolean) -> Unit, + onLeaguePick: (Int?) -> Unit, + formPlanned: String, + onPlanned: (String) -> Unit, + formGender: String, + formAge: String, + genderMenu: Boolean, + onGenderMenu: (Boolean) -> Unit, + ageMenu: Boolean, + onAgeMenu: (Boolean) -> Unit, + onGenderPick: (String) -> Unit, + onAgePick: (String) -> Unit, + saving: Boolean, + team: ClubTeamDto?, + onSave: () -> Unit, +) { + val myStatus = team?.let { myTtStatus(it, t) } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (myStatus != null && myStatus.missingItemLabels.isNotEmpty() && !myStatus.complete) { + Card(elevation = 1.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(t("teamManagement.missingConfigSummary", "Offene Punkte"), fontWeight = FontWeight.SemiBold) + myStatus.missingItemLabels.forEach { line -> + Text("• $line", style = MaterialTheme.typography.caption) + } + } + } + } + OutlinedTextField( + value = formName, + onValueChange = onName, + label = { Text(t("mobile.teamName", "Name")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = canWrite, + ) + Text(t("mobile.league", "Liga"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton( + onClick = { onLeagueMenu(true) }, + modifier = Modifier.fillMaxWidth(), + enabled = canWrite, + ) { + Text( + formLeagueId?.let { id -> leagues.find { it.id == id }?.name } + ?: t("mobile.noLeague", "Keine Liga"), + ) + } + DropdownMenu(expanded = leagueMenu, onDismissRequest = { onLeagueMenu(false) }) { + DropdownMenuItem(onClick = { onLeaguePick(null); onLeagueMenu(false) }) { + Text(t("mobile.noLeague", "Keine Liga")) + } + leagues.forEach { lg -> + DropdownMenuItem(onClick = { onLeaguePick(lg.id); onLeagueMenu(false) }) { + Text(lg.name) + } + } + } + } + OutlinedTextField( + value = formPlanned, + onValueChange = onPlanned, + label = { Text(t("mobile.plannedLeague", "Geplante Liga")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = canWrite, + ) + Text(t("mobile.teamGenderShort", "Geschlecht"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { onGenderMenu(true) }, enabled = canWrite, modifier = Modifier.fillMaxWidth()) { + Text(formGender) + } + DropdownMenu(expanded = genderMenu, onDismissRequest = { onGenderMenu(false) }) { + listOf("open", "female").forEach { g -> + DropdownMenuItem(onClick = { onGenderPick(g); onGenderMenu(false) }) { Text(g) } + } + } + } + Text(t("mobile.teamAgeShort", "Altersklasse"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { onAgeMenu(true) }, enabled = canWrite, modifier = Modifier.fillMaxWidth()) { + Text(formAge) + } + DropdownMenu(expanded = ageMenu, onDismissRequest = { onAgeMenu(false) }) { + listOf("adult", "J19", "J17", "J15", "J13", "J11").forEach { a -> + DropdownMenuItem(onClick = { onAgePick(a); onAgeMenu(false) }) { Text(a) } + } + } + } + if (canWrite) { + Button(onClick = onSave, enabled = !saving && formName.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + if (saving) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp)) + else Text(if (isCreateMode) t("mobile.teamCreate", "Anlegen") else t("common.save", "Speichern")) + } + } + } +} + +@Composable +private fun StatsTab( + t: (String, String) -> String, + loading: Boolean, + stats: List, + secondHalf: Boolean, + members: List, + onRefresh: () -> Unit, +) { + val byId = remember(members) { members.associateBy { it.id } } + Column(Modifier.fillMaxSize()) { + TextButton(onClick = onRefresh, enabled = !loading) { + Text(t("teamManagement.refreshStats", "Aktualisieren")) + } + if (loading) CircularProgressIndicator() + else if (stats.isEmpty()) { + Text(t("teamManagement.noPlayerStats", "Keine Spielerstatistik.")) + } else { + LazyColumn { + item { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(t("teamManagement.player", "Spieler"), fontWeight = FontWeight.SemiBold) + Text("(Q)TTR", fontWeight = FontWeight.SemiBold) + Text(t("teamManagement.season", "Saison"), fontWeight = FontWeight.SemiBold) + Text( + if (secondHalf) t("teamManagement.secondHalf", "Rück.") else t("teamManagement.firstHalf", "Vorr."), + fontWeight = FontWeight.SemiBold, + ) + } + } + items(stats, key = { it.memberId }) { s -> + val m = byId[s.memberId] + val qttrLabel = m?.qttr?.takeIf { it > 0 }?.toString() ?: "–" + Column(Modifier.padding(vertical = 6.dp)) { + Text("${s.firstName} ${s.lastName}", fontWeight = FontWeight.Medium) + Text( + "(Q)TTR: $qttrLabel · Saison: ${s.totalSeason} · " + + if (secondHalf) "RR: ${s.totalSecondHalf}" else "VR: ${s.totalFirstHalf}", + style = MaterialTheme.typography.caption, + ) + } + } + } + } + } +} + +@Composable +private fun LineupTab( + t: (String, String) -> String, + context: Context, + canWrite: Boolean, + team: ClubTeamDto, + seasonLabel: String, + members: List, + lineupHalf: String, + onHalfChange: (String) -> Unit, + loading: Boolean, + saving: Boolean, + lineupRows: List, + onPersist: (List) -> Unit, + onReload: () -> Unit, +) { + val tAge = TeamEditorLineupLogic.configuredTeamAgeGroup(team.teamAgeGroup, team.league?.name) + val tGender = TeamEditorLineupLogic.configuredTeamGender(team.teamGender, team.league?.name) + val groups = remember(members, team, seasonLabel) { + TeamEditorLineupLogic.buildLineupGroups( + members = members, + teamAgeGroup = tAge, + teamGender = tGender, + seasonLabel = seasonLabel, + labelAdults = t("members.adults", "Erwachsene"), + labelUnknown = t("unknown", "Unbekannt"), + ) + } + var ordered by remember(lineupRows, members, lineupHalf) { + mutableStateOf( + lineupRows.sortedBy { it.position }.mapNotNull { row -> + row.member ?: members.find { it.id == row.memberId } + }, + ) + } + + val selectedIds = ordered.map { it.id }.toSet() + val availableCount = remember(groups, ordered) { + val ids = ordered.map { it.id }.toSet() + groups.sumOf { g -> g.members.count { it.member.id !in ids } } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Card(elevation = 1.dp, modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.surface) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + t("mobile.teamLineupIntroTitle", "Mannschaftsmeldung"), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.subtitle1, + ) + Text( + t( + "mobile.teamLineupIntroBody", + "Spieler unten unter „Weitere Spieler“ mit Hinzufügen in die Meldung übernehmen. Reihenfolge mit ↑/↓ ändern, mit − wieder entfernen. Änderungen werden gespeichert.", + ), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.85f), + ) + if (!canWrite) { + Text( + t("mobile.teamLineupReadOnly", "Nur Lesezugriff: Meldung kann nicht geändert werden."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.error, + ) + } + } + } + + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("common.period", "Halbserie"), modifier = Modifier.align(Alignment.CenterVertically)) + listOf("first_half" to "VR", "second_half" to "RR").forEach { (v, label) -> + Button( + onClick = { onHalfChange(v) }, + enabled = !saving && lineupHalf != v, + ) { Text(label) } + } + TextButton(onClick = onReload, enabled = !loading) { + Text(t("teamManagement.refreshStats", "Aktualisieren")) + } + } + if (loading) CircularProgressIndicator(Modifier.padding(8.dp)) + + Text( + t("teamManagement.selectedLineup", "Aktuelle Meldung") + " (${ordered.size})", + fontWeight = FontWeight.SemiBold, + ) + if (ordered.isEmpty()) { + Text(t("teamManagement.lineupEmpty", "Noch niemand gemeldet.")) + } else { + ordered.forEachIndexed { index, m -> + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${index + 1}. ${m.firstName} ${m.lastName} (${TeamEditorLineupLogic.lineupRatingLabel(m)})") + Row { + TextButton( + onClick = { + if (index <= 0) return@TextButton + val next = ordered.toMutableList().also { it.swap(index, index - 1) } + val warn = TeamEditorLineupLogic.lineupGapViolationMessage(next) { hi, lo -> + t("mobile.lineupGapWarn", "QTTR-Abstand >30: $hi über $lo") + } + if (warn != null) { + android.widget.Toast.makeText(context, warn, android.widget.Toast.LENGTH_LONG).show() + return@TextButton + } + ordered = next + onPersist(next.mapIndexed { i, mem -> TeamLineupAssignmentItem(mem.id, i + 1) }) + }, + enabled = canWrite && !saving && index > 0, + ) { Text("↑") } + TextButton( + onClick = { + if (index >= ordered.lastIndex) return@TextButton + val next = ordered.toMutableList().also { it.swap(index, index + 1) } + val warn = TeamEditorLineupLogic.lineupGapViolationMessage(next) { hi, lo -> + t("mobile.lineupGapWarn", "QTTR-Abstand >30: $hi über $lo") + } + if (warn != null) { + android.widget.Toast.makeText(context, warn, android.widget.Toast.LENGTH_LONG).show() + return@TextButton + } + ordered = next + onPersist(next.mapIndexed { i, mem -> TeamLineupAssignmentItem(mem.id, i + 1) }) + }, + enabled = canWrite && !saving && index < ordered.lastIndex, + ) { Text("↓") } + TextButton( + onClick = { + val next = ordered.toMutableList().also { it.removeAt(index) } + ordered = next + onPersist(next.mapIndexed { i, mem -> TeamLineupAssignmentItem(mem.id, i + 1) }) + }, + enabled = canWrite && !saving, + ) { Text("−") } + } + } + } + } + + Divider() + + Text( + t("teamManagement.availableLineupMembers", "Weitere Spieler") + " ($availableCount)", + fontWeight = FontWeight.SemiBold, + ) + if (groups.isEmpty()) { + Card(elevation = 0.dp, modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.surface) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + t("mobile.teamLineupNoEligibleTitle", "Keine meldefähigen Spieler"), + fontWeight = FontWeight.Medium, + ) + Text( + t( + "mobile.teamLineupNoEligibleBody", + "Es werden nur aktive Vereinsmitglieder angezeigt, die zur Alters-/Geschlechtsklasse dieser Mannschaft passen (Geburtsdatum, ggf. Erwachsenen-Freigabe bei Jugendlichen in Erwachsenen-Mannschaften).", + ), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.82f), + ) + } + } + } else if (availableCount == 0) { + Text( + t( + "mobile.teamLineupAllInLineup", + "Alle passenden Spieler sind bereits in der Meldung – oder es gibt keine weiteren passenden Mitglieder.", + ), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.82f), + ) + } + groups.forEach { g -> + val rows = g.members.filter { it.member.id !in selectedIds } + if (rows.isEmpty()) return@forEach + Text(g.label, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 4.dp)) + rows.forEach { row -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("${row.member.firstName} ${row.member.lastName}") + Text( + "${row.memberAgeGroupLabel} · ${row.lineupRatingLabel} · ${row.eligibilityLabel}", + style = MaterialTheme.typography.caption, + ) + } + OutlinedButton( + onClick = { + val next = ordered + row.member + val warn = TeamEditorLineupLogic.lineupGapViolationMessage(next) { hi, lo -> + t("mobile.lineupGapWarn", "QTTR-Abstand >30: $hi über $lo") + } + if (warn != null) { + android.widget.Toast.makeText(context, warn, android.widget.Toast.LENGTH_LONG).show() + return@OutlinedButton + } + ordered = next + onPersist(next.mapIndexed { i, mem -> TeamLineupAssignmentItem(mem.id, i + 1) }) + }, + enabled = canWrite && !saving, + ) { + Text(t("mobile.teamLineupAddToLineup", "Hinzufügen")) + } + } + } + } + + Divider() + + Card(elevation = 0.dp, modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.surface) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + t("mobile.teamPlanningTitle", "Mannschafts-Planung (Web)"), + fontWeight = FontWeight.Medium, + ) + Text( + t( + "mobile.teamPlanningBody", + "Die Planungs-Ansicht mit Pool, mehreren Mannschaften und Drag-and-Drop gibt es in der Web-App unter Team-Verwaltung → Planung. In dieser App bearbeitest du die Meldung je Mannschaft hier im Tab Aufstellung.", + ), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.82f), + ) + } + } + } +} + +private fun MutableList.swap(i: Int, j: Int) { + val tmp = this[i] + this[i] = this[j] + this[j] = tmp +} + +@Composable +private fun DocumentsTab( + t: (String, String) -> String, + canWrite: Boolean, + team: ClubTeamDto, + documents: List, + loading: Boolean, + parsingId: Int?, + onPickUpload: (String) -> Unit, + onParse: (de.tt_tagebuch.shared.api.models.TeamDocumentDto) -> Unit, + onOpen: (de.tt_tagebuch.shared.api.models.TeamDocumentDto) -> Unit, +) { + fun latest(type: String) = documents.filter { it.documentType == type }.maxByOrNull { it.id } + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (loading) CircularProgressIndicator() + DocCard( + title = t("teamManagement.codeList", "Meldeliste"), + latest = latest("code_list"), + canWrite = canWrite, + parsingId = parsingId, + onUpload = { onPickUpload("code_list") }, + onParse = onParse, + onOpen = onOpen, + t = t, + ) + DocCard( + title = t("teamManagement.pinList", "PIN-Liste"), + latest = latest("pin_list"), + canWrite = canWrite, + parsingId = parsingId, + onUpload = { onPickUpload("pin_list") }, + onParse = onParse, + onOpen = onOpen, + t = t, + ) + } +} + +@Composable +private fun DocCard( + title: String, + latest: de.tt_tagebuch.shared.api.models.TeamDocumentDto?, + canWrite: Boolean, + parsingId: Int?, + onUpload: () -> Unit, + onParse: (de.tt_tagebuch.shared.api.models.TeamDocumentDto) -> Unit, + onOpen: (de.tt_tagebuch.shared.api.models.TeamDocumentDto) -> Unit, + t: (String, String) -> String, +) { + Card(elevation = 1.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text( + latest?.createdAt ?: t("teamManagement.noDocumentUploadYet", "Noch kein Upload"), + style = MaterialTheme.typography.caption, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (canWrite) { + Button(onClick = onUpload) { Text(t("teamManagement.uploadNewVersion", "Hochladen")) } + } + if (latest != null) { + TextButton(onClick = { onOpen(latest) }) { + Text(t("teamManagement.openDocument", "Öffnen")) + } + TextButton( + onClick = { onParse(latest) }, + enabled = parsingId != latest.id, + ) { + Text(t("teamManagement.parseDocument", "Parsen")) + } + } + } + } + } +} + +@Composable +private fun JobsTab( + t: (String, String) -> String, + block: de.tt_tagebuch.shared.api.models.SchedulerJobBlockDto?, + teamId: Int, +) { + val detail = block?.teamDetails?.find { it.clubTeamId == teamId } + Column { + Text(t("teamManagement.automaticJobs", "Automatische Jobs"), fontWeight = FontWeight.SemiBold) + if (detail == null) { + Text(t("teamManagement.noAutomaticUpdate", "Keine Job-Daten für diese Mannschaft.")) + } else { + Text("${t("teamManagement.lastUpdated", "Zuletzt")}: ${detail.lastRun ?: "–"}") + Text( + "${t("teamManagement.status", "Status")}: " + + if (detail.success) t("teamManagement.successful", "OK") else t("teamManagement.error", "Fehler"), + ) + } + } +} + +@Composable +private fun MyTtTab( + t: (String, String) -> String, + team: ClubTeamDto, + busy: Boolean, + url: String, + onUrl: (String) -> Unit, + leagueUrl: String, + onLeagueUrl: (String) -> Unit, + createLeague: Boolean, + onCreateLeague: (Boolean) -> Unit, + createSeason: Boolean, + onCreateSeason: (Boolean) -> Unit, + createSeasonLeague: Boolean, + onCreateSeasonLeague: (Boolean) -> Unit, + onParse: () -> Unit, + onConfigureTeam: () -> Unit, + onConfigureLeague: () -> Unit, + onFetch: () -> Unit, +) { + val st = myTtStatus(team, t) + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + when { + st.complete -> t("teamManagement.fullyConfigured", "Vollständig") + st.partial -> t("teamManagement.partiallyConfigured", "Teilweise") + else -> t("teamManagement.notConfigured", "Nicht konfiguriert") + }, + fontWeight = FontWeight.SemiBold, + ) + st.missingItemLabels.forEach { Text("• $it", style = MaterialTheme.typography.caption) } + if (st.complete) { + Button(onClick = onFetch, enabled = !busy) { + Text(t("teamManagement.manualFetch", "Daten abrufen")) + } + } + OutlinedTextField( + value = url, + onValueChange = onUrl, + label = { Text(t("teamManagement.myTischtennisUrlPlaceholder", "MyTT-Team-URL")) }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onParse, enabled = !busy && url.isNotBlank()) { + Text(t("teamManagement.parseUrlAction", "URL prüfen")) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material.Switch(checked = createLeague, onCheckedChange = onCreateLeague) + Text(t("mobile.teamMyTtCreateLeague", "Liga anlegen falls nötig")) + } + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material.Switch(checked = createSeason, onCheckedChange = onCreateSeason) + Text(t("mobile.teamMyTtCreateSeason", "Saison anlegen falls nötig")) + } + Button(onClick = onConfigureTeam, enabled = !busy && url.isNotBlank()) { + Text(t("mobile.teamMyTtLinkTeam", "Team verknüpfen")) + } + Spacer(Modifier.height(16.dp)) + Text(t("mobile.teamMyTtLeagueUrlTitle", "Tabellen-URL (Liga)"), fontWeight = FontWeight.Medium) + OutlinedTextField( + value = leagueUrl, + onValueChange = onLeagueUrl, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material.Switch(checked = createSeasonLeague, onCheckedChange = onCreateSeasonLeague) + Text(t("mobile.teamMyTtCreateSeasonLeague", "Saison anlegen (Liga)")) + } + Button(onClick = onConfigureLeague, enabled = !busy && leagueUrl.isNotBlank()) { + Text(t("mobile.teamMyTtLinkLeague", "Liga verknüpfen")) + } + if (busy) CircularProgressIndicator() + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt index 542a4920..314601c6 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt @@ -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>(emptyList()) } - var selectedSeasonId by remember { mutableStateOf(null) } - var teams by remember { mutableStateOf>(emptyList()) } - var leagues by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var search by remember { mutableStateOf("") } + var seasons by remember(clubId) { mutableStateOf>(emptyList()) } + var selectedSeasonId by remember(clubId) { mutableStateOf(null) } + var teams by remember(clubId) { mutableStateOf>(emptyList()) } + var leagues by remember(clubId) { mutableStateOf>(emptyList()) } + var loading by remember(clubId) { mutableStateOf(true) } + var error by remember(clubId) { mutableStateOf(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(null) } - var formName by remember { mutableStateOf("") } - var formLeagueId by remember { mutableStateOf(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(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(null) } var infoMessage by remember { mutableStateOf(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 diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt index 72bd555b..bfaa7996 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt @@ -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() 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() + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt index f23afb23..d70561f1 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt @@ -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 { + return client.http.get("/api/club-teams/$clubTeamId/lineup") { + parameter("half", half) + }.body() + } + + suspend fun updateLineup(clubTeamId: Int, body: TeamLineupUpdateBody): List { + return client.http.put("/api/club-teams/$clubTeamId/lineup") { + setBody(body) + }.body() + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt index 9668f724..e8ce3684 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt @@ -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 { + 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) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt index 85a6a14b..34efe91b 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt @@ -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() } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TeamDocumentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TeamDocumentsApi.kt new file mode 100644 index 00000000..c6dee8e1 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TeamDocumentsApi.kt @@ -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 { + 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") + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TeamEditorModels.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TeamEditorModels.kt new file mode 100644 index 00000000..d7f32471 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TeamEditorModels.kt @@ -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, + 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 = 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, +)