feat(TrainingStats): enhance training statistics view with collapsible panels and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s

- Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics.
- Added new localization keys for training statistics panels in both German and English.
- Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience.
- Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation.
- Introduced new API methods for quick updates and transfers of member data, streamlining member management processes.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 16:15:19 +02:00
parent 7981371136
commit 6ef1d79a5f
17 changed files with 2852 additions and 373 deletions

View File

@@ -2,7 +2,9 @@ package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse
import de.tt_tagebuch.shared.api.models.MemberSetBody
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
import io.ktor.client.call.body
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
@@ -14,6 +16,7 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.client.request.forms.MultiPartFormDataContent
import kotlinx.serialization.json.JsonObject
class MembersApi(
private val client: AuthedHttpClient,
@@ -28,6 +31,24 @@ class MembersApi(
}
}
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
client.http.post("/api/clubmembers/update-ratings/$clubId")
}
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse {
return client.http.post("/api/clubmembers/quick-update-test-membership/$clubId/$memberId").body()
}
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse {
return client.http.post("/api/clubmembers/quick-update-member-form/$clubId/$memberId").body()
}
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject {
return client.http.post("/api/clubmembers/transfer/$clubId") {
setBody(body)
}.body()
}
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
client.http.post("/api/clubmembers/image/$clubId/$memberId") {
if (makePrimary) {

View File

@@ -107,3 +107,8 @@ fun UserClubPermissions.canReadStatistics(): Boolean {
if (isOwner) return true
return permissions.boolAt("statistics", "read")
}
fun UserClubPermissions.canWriteMyTischtennis(): Boolean {
if (isOwner) return true
return permissions.boolAt("mytischtennis", "write")
}

View File

@@ -27,6 +27,8 @@ data class Member(
val trainingParticipations: Int? = null,
val notInTraining: Boolean? = null,
val missedTrainingWeeks: Int? = null,
/** Aus Training-Stats gemergt (aktive Mitglieder); leer wenn keine Zuordnung. */
val trainingGroups: List<TrainingStatsTrainingGroup> = emptyList(),
val contacts: List<MemberContactDto> = emptyList(),
val images: List<MemberImageDto> = emptyList(),
val primaryImageId: Int? = null,

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberQuickMutationResponse(
val success: Boolean? = null,
val message: String? = null,
val error: String? = null,
)

View File

@@ -45,3 +45,16 @@ data class MemberTransferConfigSaveBody(
val useBulkMode: Boolean = false,
val bulkWrapperTemplate: String? = null,
)
@Serializable
data class MemberTransferRunBody(
val transferEndpoint: String,
val transferMethod: String = "POST",
val transferFormat: String = "json",
val transferTemplate: String,
val useBulkMode: Boolean = false,
val bulkWrapperTemplate: String? = null,
val loginEndpoint: String? = null,
val loginFormat: String? = null,
val loginCredentials: JsonObject? = null,
)

View File

@@ -3,24 +3,30 @@ package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
import de.tt_tagebuch.shared.api.models.CreateTrainingTimeBody
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
import de.tt_tagebuch.shared.api.models.MemberSetBody
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.JsonObject
class MembersManager(
private val membersApi: MembersApi,
private val trainingGroupsApi: TrainingGroupsApi,
private val memberActivitiesApi: MemberActivitiesApi,
private val trainingTimesApi: TrainingTimesApi,
private val trainingStatsApi: TrainingStatsApi,
) {
private val _state = MutableStateFlow(MembersState())
val state: StateFlow<MembersState> = _state.asStateFlow()
@@ -29,19 +35,37 @@ class MembersManager(
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val members = membersApi.listMembers(clubId)
val merged = runCatching { trainingStatsApi.getStats(clubId) }
.fold(
onSuccess = { mergeTrainingStatsIntoMembers(members, it.members) },
onFailure = { members },
)
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
_state.value = _state.value.copy(members = members, isLoading = false)
_state.value = _state.value.copy(members = merged, isLoading = false)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden"))
}
}
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
membersApi.updateRatingsFromMyTischtennis(clubId)
}
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse =
membersApi.quickUpdateTestMembership(clubId, memberId)
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse =
membersApi.quickUpdateMemberFormHandedOver(clubId, memberId)
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject =
membersApi.transferMembers(clubId, body)
suspend fun saveMember(clubId: Int, body: MemberSetBody) {
membersApi.setMember(clubId, body)
}
suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray) {
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = true)
suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = makePrimary)
}
suspend fun listTrainingGroups(clubId: Int): List<TrainingGroupDto> = trainingGroupsApi.listGroups(clubId)
@@ -94,3 +118,27 @@ class MembersManager(
_state.value = MembersState()
}
}
private fun mergeTrainingStatsIntoMembers(members: List<Member>, stats: List<TrainingStatsMember>): List<Member> {
val byId = stats.associateBy { it.id }
return members.map { m ->
val s = byId[m.id]
if (s == null) {
m.copy(
trainingParticipations = 0,
missedTrainingWeeks = 0,
notInTraining = false,
lastTraining = null,
trainingGroups = emptyList(),
)
} else {
m.copy(
trainingParticipations = s.participationTotal,
lastTraining = s.lastTraining,
notInTraining = s.notInTraining,
missedTrainingWeeks = s.missedTrainingWeeks,
trainingGroups = s.trainingGroups,
)
}
}
}