feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data.
- Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs.
- Enhanced the ClubService to handle normalization of country and state codes during updates.
- Added new routes and middleware to support the training cancellation feature and calendar integration in the backend.
- Updated frontend navigation to include a calendar link, improving user access to scheduling features.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View File

@@ -0,0 +1,38 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
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 io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class ApiLogsApi(
private val client: AuthedHttpClient,
) {
suspend fun listLogs(
limit: Int = 50,
offset: Int = 0,
logType: String? = null,
method: String? = null,
statusCode: Int? = null,
pathContains: String? = null,
): ApiLogsListPageDto {
val env = client.http.get("/api/logs") {
parameter("limit", limit)
parameter("offset", offset)
logType?.takeIf { it.isNotBlank() }?.let { parameter("logType", it) }
method?.takeIf { it.isNotBlank() }?.let { parameter("method", it) }
statusCode?.let { parameter("statusCode", it) }
pathContains?.takeIf { it.isNotBlank() }?.let { parameter("path", it) }
}.body<ApiLogsListEnvelopeDto>()
return env.data ?: ApiLogsListPageDto()
}
suspend fun getLog(id: Int): ApiLogDetailDto? {
val env = client.http.get("/api/logs/$id").body<ApiLogDetailEnvelopeDto>()
return env.data
}
}

View File

@@ -0,0 +1,25 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody
import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class ClubApprovalsApi(
private val client: AuthedHttpClient,
) {
suspend fun listPending(clubId: Int): List<PendingUserClubJoinDto> {
return client.http.get("/api/clubs/pending/$clubId").body()
}
suspend fun approve(body: ClubAccessDecisionBody) {
client.http.post("/api/clubs/approve") { setBody(body) }
}
suspend fun reject(body: ClubAccessDecisionBody) {
client.http.post("/api/clubs/reject") { setBody(body) }
}
}

View File

@@ -0,0 +1,17 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class ClubTeamsApi(
private val client: AuthedHttpClient,
) {
suspend fun listClubTeams(clubId: Int, seasonId: Int? = null): List<ClubTeamDto> {
return client.http.get("/api/club-teams/club/$clubId") {
seasonId?.let { parameter("seasonid", it) }
}.body()
}
}

View File

@@ -0,0 +1,37 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.setBody
class MatchesApi(
private val client: AuthedHttpClient,
) {
suspend fun listMatchesForLeagues(clubId: Int, seasonId: Int? = null): List<ScheduleMatchDto> {
return client.http.get("/api/matches/leagues/$clubId/matches") {
seasonId?.let { parameter("seasonid", it) }
}.body()
}
suspend fun listMatchesForLeague(clubId: Int, leagueId: Int, scope: String = "own"): List<ScheduleMatchDto> {
return client.http.get("/api/matches/leagues/$clubId/matches/$leagueId") {
parameter("scope", scope)
}.body()
}
suspend fun leagueTable(clubId: Int, leagueId: Int): List<LeagueTableRowDto> {
return client.http.get("/api/matches/leagues/$clubId/table/$leagueId").body()
}
suspend fun updateMatchPlayers(matchId: Int, body: UpdateMatchPlayersBody) {
client.http.patch("/api/matches/$matchId/players") {
setBody(body)
}
}
}

View File

@@ -0,0 +1,19 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto
import io.ktor.client.call.body
import io.ktor.client.request.get
class OfficialTournamentsApi(
private val client: AuthedHttpClient,
) {
suspend fun listForClub(clubId: Int): List<OfficialTournamentListRowDto> {
return client.http.get("/api/official-tournaments/$clubId").body()
}
suspend fun listParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> {
return client.http.get("/api/official-tournaments/$clubId/participations/summary").body()
}
}

View File

@@ -1,9 +1,18 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AvailableRoleDto
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
import de.tt_tagebuch.shared.api.models.UpdateUserApprovedBody
import de.tt_tagebuch.shared.api.models.UpdateUserCustomPermissionsBody
import de.tt_tagebuch.shared.api.models.UpdateUserRoleBody
import de.tt_tagebuch.shared.api.models.UserClubPermissions
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.put
import io.ktor.client.request.setBody
class PermissionsApi(
private val client: AuthedHttpClient,
@@ -11,5 +20,37 @@ class PermissionsApi(
suspend fun getUserPermissions(clubId: Int): UserClubPermissions {
return client.http.get("/api/permissions/$clubId").body()
}
suspend fun listAvailableRoles(): List<AvailableRoleDto> {
return client.http.get("/api/permissions/roles/available").body()
}
suspend fun getPermissionStructure(): Map<String, PermissionResourceDto> {
return client.http.get("/api/permissions/structure/all").body()
}
suspend fun listClubMembersWithPermissions(clubId: Int, cacheBust: Boolean = false): List<ClubPermissionMemberDto> {
return client.http.get("/api/permissions/$clubId/members") {
if (cacheBust) parameter("t", kotlin.random.Random.nextLong().toString())
}.body()
}
suspend fun updateUserRole(clubId: Int, userId: Int, role: String) {
client.http.put("/api/permissions/$clubId/user/$userId/role") {
setBody(UpdateUserRoleBody(role = role))
}
}
suspend fun updateUserStatus(clubId: Int, userId: Int, approved: Boolean) {
client.http.put("/api/permissions/$clubId/user/$userId/status") {
setBody(UpdateUserApprovedBody(approved = approved))
}
}
suspend fun updateUserCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) {
client.http.put("/api/permissions/$clubId/user/$userId/permissions") {
setBody(UpdateUserCustomPermissionsBody(permissions = permissions))
}
}
}

View File

@@ -0,0 +1,122 @@
package de.tt_tagebuch.shared.api
/**
* Rollen-Standardrechte (analog [backend/services/permissionService.js] ROLE_PERMISSIONS)
* für die mobile Berechtigungs-UI (Erbe / explizit erlauben / explizit verbieten).
*/
object RolePermissionMatrix {
private val admin = resourceMap(
diary = triple(true, true, true),
members = triple(true, true, true),
teams = triple(true, true, true),
schedule = triple(true, true, true),
tournaments = triple(true, true, true),
statistics = pair(true, true),
settings = pair(true, true),
permissions = pair(true, true),
approvals = pair(true, true),
mytischtennis_admin = pair(true, true),
predefined_activities = triple(true, true, true),
)
private val trainer = resourceMap(
diary = triple(true, true, true),
members = triple(true, true, false),
teams = triple(true, true, false),
schedule = triple(true, false, false),
tournaments = triple(true, true, false),
statistics = pair(true, false),
settings = pair(false, false),
permissions = pair(false, false),
approvals = pair(false, false),
mytischtennis_admin = pair(false, false),
predefined_activities = triple(true, true, true),
)
private val teamManager = resourceMap(
diary = triple(false, false, false),
members = triple(true, false, false),
teams = triple(true, true, false),
schedule = triple(true, true, false),
tournaments = triple(true, false, false),
statistics = pair(true, false),
settings = pair(false, false),
permissions = pair(false, false),
approvals = pair(false, false),
mytischtennis_admin = pair(false, false),
predefined_activities = triple(false, false, false),
)
private val tournamentManager = resourceMap(
diary = triple(false, false, false),
members = triple(true, false, false),
teams = triple(false, false, false),
schedule = triple(false, false, false),
tournaments = triple(true, true, false),
statistics = pair(true, false),
settings = pair(false, false),
permissions = pair(false, false),
approvals = pair(false, false),
mytischtennis_admin = pair(false, false),
predefined_activities = triple(false, false, false),
)
private val member = resourceMap(
diary = triple(false, false, false),
members = triple(false, false, false),
teams = triple(false, false, false),
schedule = triple(false, false, false),
tournaments = triple(false, false, false),
statistics = pair(true, false),
settings = pair(false, false),
permissions = pair(false, false),
approvals = pair(false, false),
mytischtennis_admin = pair(false, false),
predefined_activities = triple(false, false, false),
)
private fun triple(r: Boolean, w: Boolean, d: Boolean): Map<String, Boolean> =
mapOf("read" to r, "write" to w, "delete" to d)
private fun pair(r: Boolean, w: Boolean): Map<String, Boolean> =
mapOf("read" to r, "write" to w)
private fun resourceMap(
diary: Map<String, Boolean>,
members: Map<String, Boolean>,
teams: Map<String, Boolean>,
schedule: Map<String, Boolean>,
tournaments: Map<String, Boolean>,
statistics: Map<String, Boolean>,
settings: Map<String, Boolean>,
permissions: Map<String, Boolean>,
approvals: Map<String, Boolean>,
mytischtennis_admin: Map<String, Boolean>,
predefined_activities: Map<String, Boolean>,
): Map<String, Map<String, Boolean>> = mapOf(
"diary" to diary,
"members" to members,
"teams" to teams,
"schedule" to schedule,
"tournaments" to tournaments,
"statistics" to statistics,
"settings" to settings,
"permissions" to permissions,
"approvals" to approvals,
"mytischtennis_admin" to mytischtennis_admin,
"predefined_activities" to predefined_activities,
)
fun defaultsForRole(role: String): Map<String, Map<String, Boolean>> =
when (role) {
"admin" -> admin
"trainer" -> trainer
"team_manager" -> teamManager
"tournament_manager" -> tournamentManager
else -> member
}
fun defaultAction(role: String, resource: String, action: String): Boolean =
defaultsForRole(role)[resource]?.get(action) ?: false
}

View File

@@ -0,0 +1,77 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
object ScheduleLogic {
fun sortClubTeams(teams: List<ClubTeamDto>): List<ClubTeamDto> =
teams.sortedWith(compareBy({ it.league?.name ?: "" }, { it.name }))
fun sortMatches(matches: List<ScheduleMatchDto>): List<ScheduleMatchDto> =
matches.sortedWith(
compareBy(
{ it.date ?: "" },
{ it.time ?: "" },
{ it.homeTeam?.name ?: "" },
{ it.guestTeam?.name ?: "" },
),
)
fun mergeUniqueMatches(a: List<ScheduleMatchDto>, b: List<ScheduleMatchDto>): List<ScheduleMatchDto> {
val seen = LinkedHashSet<Int>()
val out = ArrayList<ScheduleMatchDto>(a.size + b.size)
for (m in a + b) {
if (seen.add(m.id)) out.add(m)
}
return out
}
fun leagueTeamNames(matches: List<ScheduleMatchDto>): List<String> {
val names = LinkedHashSet<String>()
for (m in matches) {
m.homeTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add)
m.guestTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add)
}
return names.sorted()
}
fun filterAdultLeagues(matches: List<ScheduleMatchDto>): List<ScheduleMatchDto> {
val youth = Regex("""[JM]\d|jugend""", RegexOption.IGNORE_CASE)
return matches.filter { m ->
val leagueName = m.leagueDetails?.name ?: ""
!youth.containsMatchIn(leagueName)
}
}
/**
* @param ownTeamName Name des gewählten Vereins-Teams (ClubTeam), wie im Web `selectedTeam.name`.
*/
fun applyTeamMatchScope(
ownMatches: List<ScheduleMatchDto>,
allMatches: List<ScheduleMatchDto>,
scope: ScheduleMatchScope,
ownTeamName: String,
otherTeamName: String,
): List<ScheduleMatchDto> {
val combined = sortMatches(mergeUniqueMatches(allMatches, ownMatches))
return when (scope) {
ScheduleMatchScope.All -> combined
ScheduleMatchScope.Other -> {
if (otherTeamName.isBlank()) emptyList()
else combined.filter { m ->
m.homeTeam?.name == otherTeamName || m.guestTeam?.name == otherTeamName
}
}
ScheduleMatchScope.Own ->
if (ownMatches.isNotEmpty()) sortMatches(ownMatches)
else combined.filter { m ->
m.homeTeam?.name == ownTeamName || m.guestTeam?.name == ownTeamName
}
}
}
fun teamsWithLeague(teams: List<ClubTeamDto>): List<ClubTeamDto> =
teams.filter { it.league != null && (it.league?.id ?: 0) > 0 }
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class TournamentsApi(
private val client: AuthedHttpClient,
) {
/** Query [type]: `mini` nur Minimeisterschaften; sonst alle (clientseitig nach intern/offen filtern). */
suspend fun listTournaments(clubId: Int, type: String? = null): List<InternalTournamentSummaryDto> {
return client.http.get("/api/tournament/$clubId") {
type?.takeIf { it.isNotBlank() }?.let { parameter("type", it) }
}.body()
}
suspend fun getTournament(clubId: Int, tournamentId: Int): InternalTournamentDetailDto {
return client.http.get("/api/tournament/$clubId/$tournamentId").body()
}
}

View File

@@ -0,0 +1,121 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class PendingUserDto(
val id: Int = 0,
val email: String = "",
val firstName: String? = null,
val lastName: String? = null,
)
@Serializable
data class PendingUserClubJoinDto(
val id: Int? = null,
val userId: Int = 0,
val clubId: Int? = null,
val user: PendingUserDto? = null,
)
@Serializable
data class ClubAccessDecisionBody(
val clubid: Int,
val userid: Int,
)
@Serializable
data class AvailableRoleDto(
val value: String,
val label: String,
val description: String = "",
)
@Serializable
data class PermissionResourceDto(
val label: String = "",
val actions: List<String> = emptyList(),
)
@Serializable
data class PermissionUserRefDto(
val id: Int = 0,
val email: String = "",
)
@Serializable
data class ClubPermissionMemberDto(
val userId: Int,
val user: PermissionUserRefDto? = null,
val role: String = "",
val isOwner: Boolean = false,
val approved: Boolean? = true,
val permissions: JsonObject? = null,
val effectivePermissions: JsonObject? = null,
)
@Serializable
data class UpdateUserRoleBody(
val role: String,
)
@Serializable
data class UpdateUserApprovedBody(
val approved: Boolean,
)
@Serializable
data class UpdateUserCustomPermissionsBody(
val permissions: JsonObject,
)
@Serializable
data class ApiLogListRowDto(
val id: Int,
val userId: Int? = null,
val method: String? = null,
val path: String? = null,
val statusCode: Int? = null,
val executionTime: Int? = null,
val errorMessage: String? = null,
val logType: String? = null,
val schedulerJobType: String? = null,
val createdAt: String? = null,
)
@Serializable
data class ApiLogsListPageDto(
val logs: List<ApiLogListRowDto> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val offset: Int = 0,
)
@Serializable
data class ApiLogsListEnvelopeDto(
val success: Boolean = false,
val data: ApiLogsListPageDto? = null,
)
@Serializable
data class ApiLogDetailEnvelopeDto(
val success: Boolean = false,
val data: ApiLogDetailDto? = null,
)
@Serializable
data class ApiLogDetailDto(
val id: Int = 0,
val userId: Int? = null,
val method: String? = null,
val path: String? = null,
val statusCode: Int? = null,
val executionTime: Int? = null,
val errorMessage: String? = null,
val logType: String? = null,
val schedulerJobType: String? = null,
val createdAt: String? = null,
val ipAddress: String? = null,
val userAgent: String? = null,
)

View File

@@ -30,3 +30,54 @@ fun UserClubPermissions.canWriteMembers(): Boolean {
if (isOwner) return true
return permissions.boolAt("members", "write")
}
fun UserClubPermissions.canReadSchedule(): Boolean {
if (isOwner) return true
return permissions.boolAt("schedule", "read")
}
fun UserClubPermissions.canWriteSchedule(): Boolean {
if (isOwner) return true
return permissions.boolAt("schedule", "write")
}
fun UserClubPermissions.canReadApprovals(): Boolean {
if (isOwner) return true
return permissions.boolAt("approvals", "read")
}
fun UserClubPermissions.canWriteApprovals(): Boolean {
if (isOwner) return true
return permissions.boolAt("approvals", "write")
}
/** Lesen der Berechtigungsverwaltung (Modul `permissions` im Backend). */
fun UserClubPermissions.canReadClubPermissions(): Boolean {
if (isOwner) return true
return permissions.boolAt("permissions", "read")
}
fun UserClubPermissions.canWriteClubPermissions(): Boolean {
if (isOwner) return true
return permissions.boolAt("permissions", "write")
}
fun UserClubPermissions.canReadTeams(): Boolean {
if (isOwner) return true
return permissions.boolAt("teams", "read")
}
fun UserClubPermissions.canWriteTeams(): Boolean {
if (isOwner) return true
return permissions.boolAt("teams", "write")
}
fun UserClubPermissions.canReadTournaments(): Boolean {
if (isOwner) return true
return permissions.boolAt("tournaments", "read")
}
fun UserClubPermissions.canWriteTournaments(): Boolean {
if (isOwner) return true
return permissions.boolAt("tournaments", "write")
}

View File

@@ -0,0 +1,107 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class ClubTeamLeagueDto(
val id: Int = 0,
val name: String = "",
val myTischtennisGroupId: String? = null,
val association: String? = null,
val groupname: String? = null,
)
@Serializable
data class ClubTeamSeasonDto(
val season: String = "",
)
@Serializable
data class ClubTeamDto(
val id: Int,
val name: String = "",
val clubId: Int = 0,
val leagueId: Int? = null,
val seasonId: Int? = null,
val myTischtennisTeamId: String? = null,
val teamGender: String? = null,
val teamAgeGroup: String? = null,
val plannedLeagueName: String? = null,
val league: ClubTeamLeagueDto? = null,
val season: ClubTeamSeasonDto? = null,
)
@Serializable
data class ScheduleTeamNameDto(
val name: String = "",
)
@Serializable
data class ScheduleLocationDto(
val name: String = "",
val address: String = "",
val city: String = "",
val zip: String = "",
)
@Serializable
data class ScheduleLeagueDetailsDto(
val name: String = "",
)
@Serializable
data class ScheduleMatchDto(
val id: Int,
val date: String? = null,
val time: String? = null,
val homeTeamId: Int? = null,
val guestTeamId: Int? = null,
val locationId: Int? = null,
val leagueId: Int? = null,
val code: String? = null,
val homePin: String? = null,
val guestPin: String? = null,
val homeMatchPoints: Int = 0,
val guestMatchPoints: Int = 0,
val isCompleted: Boolean = false,
val pdfUrl: String? = null,
val playersReady: List<Int> = emptyList(),
val playersPlanned: List<Int> = emptyList(),
val playersPlayed: List<Int> = emptyList(),
val homeTeam: ScheduleTeamNameDto? = null,
val guestTeam: ScheduleTeamNameDto? = null,
val location: ScheduleLocationDto? = null,
val leagueDetails: ScheduleLeagueDetailsDto? = null,
)
@Serializable
data class LeagueTableRowDto(
val teamId: Int,
val teamName: String = "",
val setsWon: Int = 0,
val setsLost: Int = 0,
/** z. B. \"3:1\" */
val matchPoints: String = "",
val tablePoints: String = "",
val pointRatio: String = "",
)
@Serializable
data class UpdateMatchPlayersBody(
val clubId: Int,
val playersReady: List<Int> = emptyList(),
val playersPlanned: List<Int> = emptyList(),
val playersPlayed: List<Int> = emptyList(),
)
enum class ScheduleMatchScope {
Own,
All,
Other,
}
enum class ScheduleViewMode {
Team,
Overall,
Adult,
}

View File

@@ -0,0 +1,59 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class InternalTournamentSummaryDto(
val id: Int = 0,
val name: String? = null,
val date: String? = null,
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
val isDoublesTournament: Boolean? = null,
)
@Serializable
data class InternalTournamentDetailDto(
val id: Int = 0,
val name: String? = null,
val date: String? = null,
val type: String? = null,
val clubId: Int? = null,
val winningSets: Int? = null,
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
val numberOfTables: Int? = null,
val numberOfGroups: Int? = null,
val advancingPerGroup: Int? = null,
val isDoublesTournament: Boolean? = null,
val bestOfEndroundSize: Int? = null,
)
@Serializable
data class OfficialTournamentListRowDto(
val id: Int = 0,
val clubId: Int? = null,
val title: String? = null,
val eventDate: String? = null,
val organizer: String? = null,
val host: String? = null,
)
@Serializable
data class OfficialParticipationEntryDto(
val memberId: Int? = null,
val memberName: String? = null,
val competitionId: Int? = null,
val competitionName: String? = null,
val placement: String? = null,
val date: String? = null,
)
@Serializable
data class OfficialParticipationBucketDto(
val tournamentId: String? = null,
val title: String? = null,
val startDate: String? = null,
val endDate: String? = null,
val entries: List<OfficialParticipationEntryDto> = emptyList(),
)

View File

@@ -0,0 +1,98 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.ApiLogsApi
import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
import de.tt_tagebuch.shared.api.models.ApiLogListRowDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class ApiLogsState(
val logs: List<ApiLogListRowDto> = emptyList(),
val total: Int = 0,
val offset: Int = 0,
val limit: Int = 50,
val isLoading: Boolean = false,
val error: String? = null,
)
class ApiLogsManager(
private val apiLogsApi: ApiLogsApi,
) {
private val _state = MutableStateFlow(ApiLogsState())
val state: StateFlow<ApiLogsState> = _state.asStateFlow()
fun clear() {
_state.value = ApiLogsState()
}
suspend fun load(
logType: String? = null,
method: String? = null,
statusCode: Int? = null,
pathContains: String? = null,
resetOffset: Boolean = false,
offsetOverride: Int? = null,
) {
val offset = offsetOverride ?: if (resetOffset) 0 else _state.value.offset
val limit = _state.value.limit
_state.update { it.copy(isLoading = true, error = null, offset = offset) }
try {
val page = apiLogsApi.listLogs(
limit = limit,
offset = offset,
logType = logType,
method = method,
statusCode = statusCode,
pathContains = pathContains,
)
_state.update {
it.copy(
logs = page.logs,
total = page.total,
limit = page.limit.takeIf { l -> l > 0 } ?: limit,
offset = page.offset,
isLoading = false,
error = null,
)
}
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
error = t.toUserMessage("Logs konnten nicht geladen werden"),
)
}
}
}
suspend fun nextPage(
logType: String? = null,
method: String? = null,
statusCode: Int? = null,
pathContains: String? = null,
) {
val s = _state.value
if (s.offset + s.logs.size >= s.total) return
load(logType, method, statusCode, pathContains, offsetOverride = s.offset + s.limit)
}
suspend fun previousPage(
logType: String? = null,
method: String? = null,
statusCode: Int? = null,
pathContains: String? = null,
) {
val s = _state.value
if (s.offset <= 0) return
load(logType, method, statusCode, pathContains, offsetOverride = (s.offset - s.limit).coerceAtLeast(0))
}
suspend fun fetchDetail(id: Int): ApiLogDetailDto? =
try {
apiLogsApi.getLog(id)
} catch (_: Throwable) {
null
}
}

View File

@@ -0,0 +1,85 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.TournamentsApi
import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
enum class ClubTournamentDisplayFilter {
Internal,
External,
Mini,
}
data class ClubInternalTournamentsState(
val filter: ClubTournamentDisplayFilter = ClubTournamentDisplayFilter.Internal,
val tournaments: List<InternalTournamentSummaryDto> = emptyList(),
val selectedId: Int? = null,
val detail: InternalTournamentDetailDto? = null,
val isLoadingList: Boolean = false,
val isLoadingDetail: Boolean = false,
val error: String? = null,
)
class ClubInternalTournamentsManager(
private val tournamentsApi: TournamentsApi,
) {
private val _state = MutableStateFlow(ClubInternalTournamentsState())
val state: StateFlow<ClubInternalTournamentsState> = _state.asStateFlow()
fun clear() {
_state.value = ClubInternalTournamentsState()
}
fun setFilter(filter: ClubTournamentDisplayFilter) {
_state.update { it.copy(filter = filter, selectedId = null, detail = null, error = null) }
}
fun selectTournament(id: Int?) {
_state.update { it.copy(selectedId = id, detail = null) }
}
suspend fun loadList(clubId: Int) {
val filter = _state.value.filter
_state.update { it.copy(isLoadingList = true, error = null) }
try {
val raw = when (filter) {
ClubTournamentDisplayFilter.Mini -> tournamentsApi.listTournaments(clubId, type = "mini")
else -> tournamentsApi.listTournaments(clubId, type = null)
}
val list = when (filter) {
ClubTournamentDisplayFilter.Mini -> raw
ClubTournamentDisplayFilter.Internal ->
raw.filter { it.miniChampionshipYear == null && it.allowsExternal != true }
ClubTournamentDisplayFilter.External ->
raw.filter { it.miniChampionshipYear == null && it.allowsExternal == true }
}
_state.update { it.copy(tournaments = list, isLoadingList = false, error = null) }
} catch (t: Throwable) {
_state.update {
it.copy(
isLoadingList = false,
error = t.toUserMessage("Vereins-Turniere konnten nicht geladen werden"),
)
}
}
}
suspend fun loadDetail(clubId: Int, tournamentId: Int) {
_state.update { it.copy(isLoadingDetail = true, error = null) }
try {
val d = tournamentsApi.getTournament(clubId, tournamentId)
_state.update { it.copy(detail = d, isLoadingDetail = false, error = null) }
} catch (t: Throwable) {
_state.update {
it.copy(
isLoadingDetail = false,
error = t.toUserMessage("Turnierdetails konnten nicht geladen werden"),
)
}
}
}
}

View File

@@ -0,0 +1,50 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class OfficialTournamentsReadState(
val tournaments: List<OfficialTournamentListRowDto> = emptyList(),
val participationBuckets: List<OfficialParticipationBucketDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
class OfficialTournamentsReadManager(
private val api: OfficialTournamentsApi,
) {
private val _state = MutableStateFlow(OfficialTournamentsReadState())
val state: StateFlow<OfficialTournamentsReadState> = _state.asStateFlow()
fun clear() {
_state.value = OfficialTournamentsReadState()
}
suspend fun load(clubId: Int) {
_state.update { it.copy(isLoading = true, error = null) }
try {
val list = api.listForClub(clubId)
val summary = api.listParticipationSummary(clubId)
_state.update {
it.copy(
tournaments = list,
participationBuckets = summary,
isLoading = false,
error = null,
)
}
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
error = t.toUserMessage("Offizielle Turniere konnten nicht geladen werden"),
)
}
}
}
}

View File

@@ -0,0 +1,48 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.ClubApprovalsApi
import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody
import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class PendingApprovalsState(
val pending: List<PendingUserClubJoinDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
class PendingApprovalsManager(
private val clubApprovalsApi: ClubApprovalsApi,
) {
private val _state = MutableStateFlow(PendingApprovalsState())
val state: StateFlow<PendingApprovalsState> = _state.asStateFlow()
fun clear() {
_state.value = PendingApprovalsState()
}
suspend fun load(clubId: Int) {
_state.update { it.copy(isLoading = true, error = null) }
try {
val list = clubApprovalsApi.listPending(clubId)
_state.update { it.copy(pending = list, isLoading = false, error = null) }
} catch (t: Throwable) {
_state.update {
it.copy(isLoading = false, error = t.toUserMessage("Freigaben konnten nicht geladen werden"))
}
}
}
suspend fun approve(clubId: Int, userId: Int) {
clubApprovalsApi.approve(ClubAccessDecisionBody(clubid = clubId, userid = userId))
load(clubId)
}
suspend fun reject(clubId: Int, userId: Int) {
clubApprovalsApi.reject(ClubAccessDecisionBody(clubid = clubId, userid = userId))
load(clubId)
}
}

View File

@@ -0,0 +1,76 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.models.AvailableRoleDto
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class PermissionsAdminState(
val members: List<ClubPermissionMemberDto> = emptyList(),
val availableRoles: List<AvailableRoleDto> = emptyList(),
val permissionStructure: Map<String, PermissionResourceDto> = emptyMap(),
val isLoading: Boolean = false,
val error: String? = null,
)
class PermissionsAdminManager(
private val permissionsApi: PermissionsApi,
) {
private val _state = MutableStateFlow(PermissionsAdminState())
val state: StateFlow<PermissionsAdminState> = _state.asStateFlow()
fun clear() {
_state.value = PermissionsAdminState()
}
suspend fun load(clubId: Int) {
_state.update { it.copy(isLoading = true, error = null) }
try {
val roles = permissionsApi.listAvailableRoles()
val structure = permissionsApi.getPermissionStructure()
val members = permissionsApi.listClubMembersWithPermissions(clubId)
_state.update {
it.copy(
availableRoles = roles,
permissionStructure = structure,
members = members,
isLoading = false,
error = null,
)
}
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
error = t.toUserMessage("Berechtigungen konnten nicht geladen werden"),
)
}
}
}
suspend fun reloadMembers(clubId: Int) {
try {
val members = permissionsApi.listClubMembersWithPermissions(clubId, cacheBust = true)
_state.update { it.copy(members = members) }
} catch (_: Throwable) { }
}
suspend fun updateRole(clubId: Int, userId: Int, role: String) {
permissionsApi.updateUserRole(clubId, userId, role)
load(clubId)
}
suspend fun updateApproved(clubId: Int, userId: Int, approved: Boolean) {
permissionsApi.updateUserStatus(clubId, userId, approved)
load(clubId)
}
suspend fun saveCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) {
permissionsApi.updateUserCustomPermissions(clubId, userId, permissions)
load(clubId)
}
}

View File

@@ -0,0 +1,258 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.ClubTeamsApi
import de.tt_tagebuch.shared.api.MatchesApi
import de.tt_tagebuch.shared.api.ScheduleLogic
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
import de.tt_tagebuch.shared.api.models.ScheduleViewMode
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class ScheduleState(
val viewMode: ScheduleViewMode = ScheduleViewMode.Team,
val teams: List<ClubTeamDto> = emptyList(),
val selectedTeamId: Int? = null,
val ownMatches: List<ScheduleMatchDto> = emptyList(),
val allMatches: List<ScheduleMatchDto> = emptyList(),
val overallMatches: List<ScheduleMatchDto> = emptyList(),
val leagueTable: List<LeagueTableRowDto> = emptyList(),
val matchScope: ScheduleMatchScope = ScheduleMatchScope.Own,
val otherTeamName: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val seasonId: Int? = null,
) {
val selectedTeam: ClubTeamDto?
get() = teams.find { it.id == selectedTeamId }
val displayedMatches: List<ScheduleMatchDto>
get() = when (viewMode) {
ScheduleViewMode.Overall -> ScheduleLogic.sortMatches(overallMatches)
ScheduleViewMode.Adult ->
ScheduleLogic.sortMatches(ScheduleLogic.filterAdultLeagues(overallMatches))
ScheduleViewMode.Team -> {
val t = selectedTeam ?: return emptyList()
ScheduleLogic.applyTeamMatchScope(
ownMatches = ownMatches,
allMatches = allMatches,
scope = matchScope,
ownTeamName = t.name,
otherTeamName = otherTeamName,
)
}
}
val leagueTeamOptions: List<String>
get() = ScheduleLogic.leagueTeamNames(ScheduleLogic.mergeUniqueMatches(allMatches, ownMatches))
}
class ScheduleManager(
private val clubTeamsApi: ClubTeamsApi,
private val matchesApi: MatchesApi,
) {
private val _state = MutableStateFlow(ScheduleState())
val state: StateFlow<ScheduleState> = _state.asStateFlow()
fun clear() {
_state.value = ScheduleState()
}
suspend fun refresh(clubId: Int) {
when (_state.value.viewMode) {
ScheduleViewMode.Team -> {
val team = _state.value.selectedTeam
if (team != null && (team.league?.id ?: 0) > 0) {
loadMatchesForTeam(clubId, team)
} else {
loadClubTeams(clubId)
}
}
ScheduleViewMode.Overall -> loadOverallSchedule(clubId)
ScheduleViewMode.Adult -> loadAdultSchedule(clubId)
}
}
suspend fun loadClubTeams(clubId: Int) {
_state.update { it.copy(isLoading = true, error = null) }
try {
val raw = clubTeamsApi.listClubTeams(clubId, _state.value.seasonId)
val sorted = ScheduleLogic.sortClubTeams(raw)
val withLeague = ScheduleLogic.teamsWithLeague(sorted)
val pick = withLeague.firstOrNull() ?: sorted.firstOrNull()
_state.update {
it.copy(
teams = sorted,
selectedTeamId = pick?.id,
isLoading = false,
error = null,
)
}
if (pick != null && (pick.league?.id ?: 0) > 0) {
loadMatchesForTeam(clubId, pick)
} else {
_state.update {
it.copy(
viewMode = ScheduleViewMode.Team,
ownMatches = emptyList(),
allMatches = emptyList(),
leagueTable = emptyList(),
overallMatches = emptyList(),
)
}
}
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
error = t.toUserMessage("Mannschaften konnten nicht geladen werden"),
)
}
}
}
suspend fun selectTeam(clubId: Int, teamId: Int) {
val team = _state.value.teams.find { it.id == teamId } ?: return
_state.update {
it.copy(
selectedTeamId = teamId,
matchScope = ScheduleMatchScope.Own,
otherTeamName = "",
)
}
if ((team.league?.id ?: 0) > 0) {
loadMatchesForTeam(clubId, team)
} else {
_state.update {
it.copy(
ownMatches = emptyList(),
allMatches = emptyList(),
leagueTable = emptyList(),
overallMatches = emptyList(),
viewMode = ScheduleViewMode.Team,
)
}
}
}
private suspend fun loadMatchesForTeam(clubId: Int, team: ClubTeamDto) {
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 table = runCatching { matchesApi.leagueTable(clubId, leagueId) }.getOrElse { emptyList() }
_state.update {
it.copy(
selectedTeamId = team.id,
ownMatches = own,
allMatches = all,
overallMatches = emptyList(),
leagueTable = table,
isLoading = false,
error = null,
)
}
ensureOtherTeamDefault()
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
ownMatches = emptyList(),
allMatches = emptyList(),
leagueTable = emptyList(),
error = t.toUserMessage("Spiele konnten nicht geladen werden"),
)
}
}
}
suspend fun loadOverallSchedule(clubId: Int) {
_state.update {
it.copy(
isLoading = true,
error = null,
viewMode = ScheduleViewMode.Overall,
matchScope = ScheduleMatchScope.Own,
otherTeamName = "",
selectedTeamId = null,
ownMatches = emptyList(),
allMatches = emptyList(),
leagueTable = emptyList(),
)
}
try {
val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId)
_state.update { it.copy(overallMatches = matches, isLoading = false, error = null) }
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
overallMatches = emptyList(),
error = t.toUserMessage("Gesamtspielplan konnte nicht geladen werden"),
)
}
}
}
suspend fun loadAdultSchedule(clubId: Int) {
_state.update {
it.copy(
isLoading = true,
error = null,
viewMode = ScheduleViewMode.Adult,
matchScope = ScheduleMatchScope.Own,
otherTeamName = "",
selectedTeamId = null,
ownMatches = emptyList(),
allMatches = emptyList(),
leagueTable = emptyList(),
)
}
try {
val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId)
_state.update { it.copy(overallMatches = matches, isLoading = false, error = null) }
} catch (t: Throwable) {
_state.update {
it.copy(
isLoading = false,
overallMatches = emptyList(),
error = t.toUserMessage("Erwachsenen-Spielplan konnte nicht geladen werden"),
)
}
}
}
fun setMatchScope(scope: ScheduleMatchScope) {
_state.update { it.copy(matchScope = scope) }
if (scope == ScheduleMatchScope.Other) {
ensureOtherTeamDefault()
} else {
_state.update { it.copy(otherTeamName = "") }
}
}
fun setOtherTeamName(name: String) {
_state.update { it.copy(otherTeamName = name) }
}
private fun ensureOtherTeamDefault() {
val options = _state.value.leagueTeamOptions
if (_state.value.otherTeamName.isBlank() && options.isNotEmpty()) {
_state.update { it.copy(otherTeamName = options.first()) }
}
}
suspend fun updateMatchPlayers(clubId: Int, matchId: Int, ready: List<Int>, planned: List<Int>, played: List<Int>) {
matchesApi.updateMatchPlayers(
matchId,
UpdateMatchPlayersBody(clubId = clubId, playersReady = ready, playersPlanned = planned, playersPlayed = played),
)
refresh(clubId)
}
}