diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 8db991da..df49d36b 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -827,6 +827,9 @@ export default { }; }, methods: { + filterRegularScheduleMatches(matches) { + return (Array.isArray(matches) ? matches : []).filter((match) => !match?.isFriendly); + }, getClubNameById(clubId) { const club = (this.clubs || []).find((item) => Number(item.id) === Number(clubId)); return club?.name || `Verein ${clubId}`; @@ -1878,7 +1881,7 @@ export default { } try { const ownResponse = await apiClient.get(`/matches/leagues/${this.currentClub}/matches/${team.league.id}`); - this.ownLeagueMatches = ownResponse.data; + this.ownLeagueMatches = this.filterRegularScheduleMatches(ownResponse.data); await this.loadLeagueMatches(team.league.id); this.applyLeagueMatchScope(); // Lade auch die Tabellendaten für diese Liga @@ -1897,7 +1900,7 @@ export default { }, async loadLeagueMatches(leagueId) { const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches/${leagueId}?scope=all`); - this.allLeagueMatches = response.data; + this.allLeagueMatches = this.filterRegularScheduleMatches(response.data); }, getCombinedLeagueMatches() { const combined = [...(this.allLeagueMatches || []), ...(this.ownLeagueMatches || [])]; @@ -1959,7 +1962,7 @@ export default { try { const seasonParam = this.selectedSeasonId ? `?seasonid=${this.selectedSeasonId}` : ''; const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches${seasonParam}`); - this.matches = this.sortMatchesByDateTime(response.data); + this.matches = this.sortMatchesByDateTime(this.filterRegularScheduleMatches(response.data)); } catch (error) { this.showInfo(this.$t('messages.error'), this.$t('schedule.errorLoadingOverallSchedule'), '', 'error'); this.matches = []; @@ -1976,7 +1979,7 @@ export default { const seasonParam = this.selectedSeasonId ? `?seasonid=${this.selectedSeasonId}` : ''; const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches${seasonParam}`); // Filtere nur Erwachsenenligen (keine Jugendligen) - const allMatches = response.data; + const allMatches = this.filterRegularScheduleMatches(response.data); this.matches = this.sortMatchesByDateTime(allMatches.filter(match => { const leagueName = match.leagueDetails?.name || ''; // Prüfe, ob es eine Jugendliga ist (J, M, Jugend im Namen) @@ -2282,6 +2285,10 @@ export default { return; } if (payload?.match && payload.matchId != null) { + if (payload.match.isFriendly) { + this.matches = this.matches.filter((match) => match.id !== payload.matchId); + return; + } const idx = this.matches.findIndex(m => m.id === payload.matchId); if (idx !== -1) { this.matches.splice(idx, 1, payload.match); diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index 2f4c6b21..cbc64a1e 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -2761,7 +2761,10 @@ export default { // Lade die Teilnehmer für diesen Trainingstag über die Participant-API const participantsResponse = await apiClient.get(`/participants/${trainingForDate.id}`); - const participants = participantsResponse.data; + const allTrainingParticipants = Array.isArray(participantsResponse.data) + ? participantsResponse.data + : []; + const participants = allTrainingParticipants.filter(participant => participant?.attendanceStatus === 'present'); if (participants && participants.length > 0) { // Lade die Member-Details für jeden Teilnehmer @@ -2794,6 +2797,8 @@ export default { } else { await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info'); } + } else if (allTrainingParticipants.length > 0) { + await this.showInfo('Hinweis', 'Keine anwesenden Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info'); } else { await this.showInfo('Hinweis', 'Keine Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info'); } @@ -4312,4 +4317,4 @@ tbody tr:hover:not(.active-match) { background-color: #6c757d; cursor: not-allowed; } - \ No newline at end of file + diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index 14fcfa24..f68169c2 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt index 4e0f3590..8f7842e3 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt @@ -324,6 +324,7 @@ internal fun TournamentEditorParticipantsTab( classes: List, participants: List, externalParticipants: List, + tournamentDate: String?, dependencies: AppDependencies, tr: (String, String) -> String, onReload: () -> Unit, @@ -337,6 +338,8 @@ internal fun TournamentEditorParticipantsTab( var extDialog by remember { mutableStateOf(false) } var extFirst by remember { mutableStateOf("") } var extLast by remember { mutableStateOf("") } + var importingFromTraining by remember { mutableStateOf(false) } + var trainingImportMessage by remember { mutableStateOf(null) } LaunchedEffect(clubId) { dependencies.membersManager.loadMembers(clubId) @@ -355,8 +358,73 @@ internal fun TournamentEditorParticipantsTab( TextButton(onClick = { showInternal = !showInternal }) { Text(if (showInternal) tr("mobile.hide", "Verbergen") else tr("mobile.show", "Anzeigen")) } } if (showInternal) { - Row { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(onClick = { addMenu = true }) { Text(tr("tournaments.addParticipant", "Teilnehmer hinzufügen")) } + Button( + enabled = !importingFromTraining, + onClick = { + scope.launch { + importingFromTraining = true + trainingImportMessage = null + runCatching { + withContext(Dispatchers.IO) { + val dateKey = normalizeTournamentDate(tournamentDate) + if (dateKey.isBlank()) { + return@withContext tr("tournaments.noTournamentDate", "Kein Turnierdatum vorhanden.") + } + val trainingDay = dependencies.diaryManager + .listDates(clubId) + .firstOrNull { normalizeTournamentDate(it.date) == dateKey } + ?: return@withContext tr("tournaments.noTrainingDayForDate", "Kein Trainingstag für dieses Datum gefunden.") + val allTrainingParticipants = dependencies.diaryManager.listTrainingParticipants(trainingDay.id) + val presentTrainingParticipants = allTrainingParticipants.filter { it.attendanceStatus == "present" } + if (presentTrainingParticipants.isEmpty()) { + return@withContext if (allTrainingParticipants.isEmpty()) { + tr("tournaments.noTrainingParticipantsForDate", "Keine Teilnehmer im Trainingstag für dieses Datum gefunden.") + } else { + tr("tournaments.noPresentTrainingParticipantsForDate", "Keine anwesenden Teilnehmer im Trainingstag für dieses Datum gefunden.") + } + } + val existing = participants.mapNotNull { it.clubMemberId }.toSet() + val toAdd = presentTrainingParticipants + .map { it.memberId } + .filter { it !in existing } + .distinct() + if (toAdd.isEmpty()) { + return@withContext tr("tournaments.trainingParticipantsAlreadyAdded", "Alle anwesenden Trainingsteilnehmer sind bereits im Turnier.") + } + toAdd.forEach { memberId -> + api.addInternalParticipant( + TournamentAddInternalParticipantBody( + clubId = clubId, + classId = null, + participant = memberId, + tournamentId = tournamentId, + ), + ) + } + tr("tournaments.trainingParticipantsLoaded", "Anwesende Trainingsteilnehmer übernommen: {count}") + .replace("{count}", toAdd.size.toString()) + } + }.fold( + onSuccess = { + trainingImportMessage = it + onReload() + }, + onFailure = { onError(it.message) }, + ) + importingFromTraining = false + } + }, + ) { + Text( + if (importingFromTraining) { + tr("mobile.loading", "Laden...") + } else { + tr("tournaments.loadParticipantsFromTraining", "Aus Trainingstag laden") + }, + ) + } DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { val existing = participants.mapNotNull { it.clubMemberId }.toSet() membersState.members @@ -388,6 +456,7 @@ internal fun TournamentEditorParticipantsTab( } } } + trainingImportMessage?.let { Text(it, style = MaterialTheme.typography.caption) } participants.forEach { p -> val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "#${p.id}" } Row( @@ -508,6 +577,10 @@ internal fun TournamentEditorParticipantsTab( } } +private fun normalizeTournamentDate(date: String?): String { + return date.orEmpty().trim().substringBefore("T") +} + @Composable private fun ClassPickerChipRow( classes: List, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt index f5ec41a6..ca27e19c 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt @@ -230,6 +230,7 @@ internal fun InternalTournamentEditorScreen( classes = classes, participants = participants, externalParticipants = externalParticipants, + tournamentDate = d.date, dependencies = dependencies, tr = ::tr, onReload = { reloadAll() }, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt index 66b31ad1..88d50cc4 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt @@ -379,6 +379,15 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean } if (permissions?.canWriteSchedule() == true) { if (m.isFriendly) { + TextButton( + onClick = { + playerError = null + playerMatch = m + detailMatch = null + }, + ) { + Text(tr("schedule.players", "Aufstellung / Spieler")) + } TextButton( onClick = { friendlyResultMatch = m @@ -525,9 +534,9 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean playerSaving = true playerError = null runCatching { - dependencies.scheduleManager.updateMatchPlayers( + dependencies.scheduleManager.updateMatchPlayersForMatch( clubId = clubId, - matchId = m.id, + match = m, ready = readyIds, planned = plannedIds, played = playedIds, diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml index e00511f4..9000c399 100644 --- a/mobile-app/gradle/libs.versions.toml +++ b/mobile-app/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # composeApp (Play Store / „Über die App“-Build) -appVersionCode = "20" -appVersionName = "1.7.0" +appVersionCode = "21" +appVersionName = "1.7.1" agp = "9.2.1" android-compileSdk = "35" android-minSdk = "24" diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt index c65560a2..a6d30181 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt @@ -80,6 +80,12 @@ class MatchesApi( } } + suspend fun updateFriendlyMatchPlayers(clubId: Int, matchId: Int, body: UpdateMatchPlayersBody) { + client.http.patch("/api/friendly-matches/$clubId/$matchId/players") { + setBody(body) + } + } + suspend fun deleteFriendlyMatch(clubId: Int, matchId: Int) { client.http.delete("/api/friendly-matches/$clubId/$matchId") } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt index 8f745b84..3b777136 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt @@ -59,6 +59,8 @@ data class ScheduleState( get() = ScheduleLogic.leagueTeamNames(ScheduleLogic.mergeUniqueMatches(allMatches, ownMatches)) } +private fun List.withoutFriendly(): List = filterNot { it.isFriendly } + class ScheduleManager( private val clubTeamsApi: ClubTeamsApi, private val matchesApi: MatchesApi, @@ -155,8 +157,8 @@ class ScheduleManager( val leagueId = team.league?.id ?: return _state.update { it.copy(isLoading = true, error = null, viewMode = ScheduleViewMode.Team) } try { - val own = matchesApi.listMatchesForLeague(clubId, leagueId, "own") - val all = matchesApi.listMatchesForLeague(clubId, leagueId, "all") + val own = matchesApi.listMatchesForLeague(clubId, leagueId, "own").withoutFriendly() + val all = matchesApi.listMatchesForLeague(clubId, leagueId, "all").withoutFriendly() val table = runCatching { matchesApi.leagueTable(clubId, leagueId) }.getOrElse { emptyList() } _state.update { it.copy( @@ -198,7 +200,7 @@ class ScheduleManager( ) } try { - val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId) + val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId).withoutFriendly() _state.update { it.copy(overallMatches = matches, isLoading = false, error = null) } } catch (t: Throwable) { _state.update { @@ -226,7 +228,7 @@ class ScheduleManager( ) } try { - val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId) + val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId).withoutFriendly() _state.update { it.copy(overallMatches = matches, isLoading = false, error = null) } } catch (t: Throwable) { _state.update { @@ -299,6 +301,27 @@ class ScheduleManager( refresh(clubId) } + suspend fun updateMatchPlayersForMatch( + clubId: Int, + match: ScheduleMatchDto, + ready: List, + planned: List, + played: List, + ) { + val body = UpdateMatchPlayersBody( + clubId = clubId, + playersReady = ready, + playersPlanned = planned, + playersPlayed = played, + ) + when { + match.isFriendly && match.isSharedFriendly -> matchesApi.updateSharedFriendlyMatchPlayers(clubId, match.id, body) + match.isFriendly -> matchesApi.updateFriendlyMatchPlayers(clubId, match.id, body) + else -> matchesApi.updateMatchPlayers(match.id, body) + } + refresh(clubId) + } + suspend fun createFriendlyMatch(clubId: Int, body: FriendlyMatchSaveBody) { val saved = matchesApi.createFriendlyMatch(clubId, body) _state.update { it.copy(friendlyMatches = ScheduleLogic.sortMatches(it.friendlyMatches + saved)) }