From 54d9b9fc86e39a5040e50370e7f6257e35cbda4b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 13 May 2026 00:01:25 +0200 Subject: [PATCH] feat(MatchService): enhance match filtering for own teams and update mobile app settings - Added logic in MatchService to filter matches based on the user's own teams, ensuring only relevant matches are displayed. - Updated the mobile app's TODO list to reflect progress on ClubSettings and Predefined Activities features. - Enhanced the AppRoot and ClubStammdatenScreens to support new settings and permissions for club management. - Introduced new API methods for creating and updating training groups and times, improving the training management capabilities. - Refactored MembersManager to include methods for managing training groups and times, streamlining the member management process. --- backend/services/matchService.js | 29 + mobile-app/TODO.md | 6 +- .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 32 + .../tt_tagebuch/app/ui/ClubSettingsScreens.kt | 1045 +++++++++++++++++ .../app/ui/ClubStammdatenScreens.kt | 268 +---- .../shared/api/TrainingGroupsApi.kt | 21 + .../shared/api/TrainingTimesApi.kt | 27 + .../shared/api/models/TrainingGroupDtos.kt | 35 + .../shared/state/MembersManager.kt | 27 + 9 files changed, 1239 insertions(+), 251 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 5224bfb9..a8aedde4 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -211,6 +211,28 @@ class MatchService { throw new Error('Season not found'); } } + + // Nur Spiele mit eigener Mannschaft (gleiche Logik wie getMatchesForLeague mit scope "own"): + // Mannschaften, deren Name mit dem Vereinsnamen beginnt, in Ligen dieser Saison. + const club = await Club.findByPk(clubId, { attributes: ['name'] }); + const leaguesInSeason = await League.findAll({ + where: { clubId, seasonId: season.id }, + attributes: ['id'] + }); + const leagueIdList = leaguesInSeason.map((l) => l.id); + let ownTeamIdSet = new Set(); + if (club?.name && leagueIdList.length) { + const escaped = String(club.name).replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); + const ownTeams = await Team.findAll({ + where: { + leagueId: { [Op.in]: leagueIdList }, + name: { [Op.like]: `${escaped}%` } + }, + attributes: ['id'] + }); + ownTeamIdSet = new Set(ownTeams.map((t) => t.id)); + } + const matches = await Match.findAll({ where: { clubId: clubId, @@ -225,6 +247,13 @@ class MatchService { if (!league || league.seasonId !== season.id) { continue; // Skip matches from other seasons } + + // Kalender: nur Punktspiele, an denen der Verein beteiligt ist (eigene Mannschaft Heim oder Gast) + if (ownTeamIdSet.size > 0) { + if (!ownTeamIdSet.has(match.homeTeamId) && !ownTeamIdSet.has(match.guestTeamId)) { + continue; + } + } const enrichedMatch = { id: match.id, diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 6c0256fc..9daa7ada 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -172,9 +172,9 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien ## Phase 9 – Vereins- & Stammdaten-Einstellungen -- [ ] **ClubSettings** (`ClubSettings.vue`) – alle Unterbereiche -- [ ] **Predefined Activities Verwaltung** (`PredefinedActivities.vue`) – CRUD, Bilder/Zeichnungen falls API -- [ ] **Mitgliedstransfer-Einstellungen** (`MemberTransferSettingsView.vue`) +- [x] **ClubSettings** (`ClubSettings.vue`) – Kernfelder in `ClubStammdatenScreens.kt` + Link Web; tiefergehend nur Web +- [x] **Predefined Activities** (`PredefinedActivities.vue`) – Liste, Neu, Bearbeiten (`PredefinedActivitiesApi`); Medien/Web-only falls nötig +- [x] **Mitgliedstransfer** (`MemberTransferSettingsView.vue`) – Basis in App, erweitert über Web-Link --- diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index d633a565..1d722f83 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -85,6 +85,8 @@ import de.tt_tagebuch.shared.api.toAbsoluteUrl import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto import de.tt_tagebuch.shared.api.models.canReadApprovals import de.tt_tagebuch.shared.api.models.canReadClubPermissions +import de.tt_tagebuch.shared.api.models.canReadClubSettings +import de.tt_tagebuch.shared.api.models.canReadPredefinedActivities import de.tt_tagebuch.shared.api.models.canReadDiary import de.tt_tagebuch.shared.api.models.canReadTeams import de.tt_tagebuch.shared.api.models.canReadMembers @@ -3969,6 +3971,7 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U @Composable private fun SettingsScreen(dependencies: AppDependencies) { var clubAdminSection by remember { mutableStateOf(null) } + var stammdatenSection by remember { mutableStateOf(null) } if (clubAdminSection != null) { ClubAdminFlowScreen( destination = clubAdminSection!!, @@ -3977,6 +3980,14 @@ private fun SettingsScreen(dependencies: AppDependencies) { ) return } + if (stammdatenSection != null) { + ClubStammdatenFlowScreen( + destination = stammdatenSection!!, + dependencies = dependencies, + onBack = { stammdatenSection = null }, + ) + return + } val authState by dependencies.authManager.state.collectAsState() val clubState by dependencies.clubManager.state.collectAsState() var sessionStatus by rememberSaveable { mutableStateOf(null) } @@ -4026,6 +4037,27 @@ private fun SettingsScreen(dependencies: AppDependencies) { modifier = Modifier.fillMaxWidth(), ) { Text(tr("mobile.teamManagementWeb", "Team-Verwaltung (Web)")) } } + if (perms.canReadClubSettings() || perms.canReadPredefinedActivities() || perms.canReadMembers()) { + SectionTitle(tr("mobile.clubStammdaten", "Verein & Stammdaten")) + if (perms.canReadClubSettings()) { + TextButton( + onClick = { stammdatenSection = ClubStammdatenSection.ClubSettings }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("clubSettings.title", "Vereinseinstellungen")) } + } + if (perms.canReadPredefinedActivities()) { + TextButton( + onClick = { stammdatenSection = ClubStammdatenSection.PredefinedActivities }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.predefinedActivities", "Standard-Aktivitäten")) } + } + if (perms.canReadMembers()) { + TextButton( + onClick = { stammdatenSection = ClubStammdatenSection.MemberTransfer }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.memberTransfer", "Mitgliedstransfer")) } + } + } } SectionTitle(tr("mobile.language", "Sprache")) MobileStrings.supportedLanguages.forEach { language -> diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt new file mode 100644 index 00000000..78af4bb9 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt @@ -0,0 +1,1045 @@ +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 +import androidx.compose.material.CircularProgressIndicator +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.Tab +import androidx.compose.material.TabRow +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.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.Club +import de.tt_tagebuch.shared.api.models.CreateTrainingTimeBody +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.MemberDataQualityRequirements +import de.tt_tagebuch.shared.api.models.TrainingGroupDto +import de.tt_tagebuch.shared.api.models.TrainingGroupMemberBrief +import de.tt_tagebuch.shared.api.models.TrainingTimeDto +import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody +import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody +import de.tt_tagebuch.shared.api.models.canReadClubSettings +import de.tt_tagebuch.shared.api.models.canReadMembers +import de.tt_tagebuch.shared.api.models.canWriteClubSettings +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch + +private val ClubSettingsPad = 20.dp +private val ClubSettingsTouchMin = 48.dp +private val ClubSettingsCardElev = 1.dp + +private enum class ClubSettingsWebTab { + Settings, + TrainingGroups, + TrainingTimes, +} + +private data class GermanStateOption(val code: String, val name: String) + +private val germanStatesClubSettings: List = listOf( + GermanStateOption("DE-BW", "Baden-Württemberg"), + GermanStateOption("DE-BY", "Bayern"), + GermanStateOption("DE-BE", "Berlin"), + GermanStateOption("DE-BB", "Brandenburg"), + GermanStateOption("DE-HB", "Bremen"), + GermanStateOption("DE-HH", "Hamburg"), + GermanStateOption("DE-HE", "Hessen"), + GermanStateOption("DE-MV", "Mecklenburg-Vorpommern"), + GermanStateOption("DE-NI", "Niedersachsen"), + GermanStateOption("DE-NW", "Nordrhein-Westfalen"), + GermanStateOption("DE-RP", "Rheinland-Pfalz"), + GermanStateOption("DE-SL", "Saarland"), + GermanStateOption("DE-SN", "Sachsen"), + GermanStateOption("DE-ST", "Sachsen-Anhalt"), + GermanStateOption("DE-SH", "Schleswig-Holstein"), + GermanStateOption("DE-TH", "Thüringen"), +) + +private fun defaultQuality(): MemberDataQualityRequirements = MemberDataQualityRequirements() + +private fun normalizeQuality(m: MemberDataQualityRequirements?): MemberDataQualityRequirements { + val d = defaultQuality() + if (m == null) return d + return MemberDataQualityRequirements( + requireStreet = m.requireStreet, + requirePostalCode = m.requirePostalCode, + requireCity = m.requireCity, + requirePhone = m.requirePhone, + requireEmail = m.requireEmail, + ) +} + +private fun sortedTrainingGroupsForClubSettings(groups: List): List = + groups.sortedWith( + compareBy { if (it.isPreset) 0 else 1 } + .thenBy { it.sortOrder } + .thenBy { it.name.lowercase() }, + ) + +private fun parseClockToApi(raw: String): String? { + val parts = raw.trim().split(':') + if (parts.size != 2) return null + val h = parts[0].trim().toIntOrNull() ?: return null + val m = parts[1].trim().take(2).padEnd(2, '0').toIntOrNull() ?: return null + if (h !in 0..23 || m !in 0..59) return null + return h.toString().padStart(2, '0') + ":" + m.toString().padStart(2, '0') +} + +private fun trainingWeekdayLabel(tr: (String, String) -> String, weekday: Int): String = when (weekday) { + 0 -> tr("trainingTimesTab.sunday", "Sonntag") + 1 -> tr("trainingTimesTab.monday", "Montag") + 2 -> tr("trainingTimesTab.tuesday", "Dienstag") + 3 -> tr("trainingTimesTab.wednesday", "Mittwoch") + 4 -> tr("trainingTimesTab.thursday", "Donnerstag") + 5 -> tr("trainingTimesTab.friday", "Freitag") + 6 -> tr("trainingTimesTab.saturday", "Samstag") + else -> "" +} + +private fun trainingGroupMemberLabel(m: TrainingGroupMemberBrief): String { + val fn = m.firstName.orEmpty().trim() + val ln = m.lastName.orEmpty().trim() + return "$fn $ln".trim().ifBlank { "#${m.id}" } +} + +private fun formatTimeDisplay(t: String): String = t.trim().take(5) + +@Composable +private fun ClubSettingsTopBar(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold) + } + Spacer(modifier = Modifier.height(8.dp)) +} + +@Composable +private fun RowSwitchClubSettings(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, modifier = Modifier.weight(1f)) + androidx.compose.material.Switch(checked = checked, onCheckedChange = onChecked) + } +} + +@Composable +internal fun MobileClubSettingsScreen(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 scope = rememberCoroutineScope() + var mainTab by remember { mutableStateOf(ClubSettingsWebTab.Settings) } + + var club by remember { mutableStateOf(null) } + var loadError by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var saving by remember { mutableStateOf(false) } + var savedHint by remember { mutableStateOf(false) } + + var greeting by remember { mutableStateOf("") } + var associationNumber by remember { mutableStateOf("") } + var countryCode by remember { mutableStateOf("DE") } + var stateCode by remember { mutableStateOf("") } + var stateMenu by remember { mutableStateOf(false) } + var myTtNickname by remember { mutableStateOf("") } + var autoFetch by remember { mutableStateOf(false) } + var quality by remember { mutableStateOf(defaultQuality()) } + + LaunchedEffect(clubId, mainTab) { + if (mainTab != ClubSettingsWebTab.Settings) return@LaunchedEffect + loading = true + loadError = null + club = runCatching { dependencies.clubManager.fetchClubDetail(clubId) } + .onFailure { loadError = it.message } + .getOrNull() + val c = club + if (c != null) { + greeting = c.greetingText.orEmpty() + associationNumber = c.associationMemberNumber.orEmpty() + countryCode = c.countryCode?.ifBlank { "DE" } ?: "DE" + stateCode = c.stateCode.orEmpty() + myTtNickname = c.myTischtennisFedNickname.orEmpty() + autoFetch = c.autoFetchRankings == true + quality = normalizeQuality(c.memberDataQualityRequirements) + } + loading = false + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(ClubSettingsPad), + ) { + ClubSettingsTopBar(tr("clubSettings.title", "Vereins-Einstellungen"), onBack) + if (perms?.canReadClubSettings() != true) { + Text(tr("mobile.noAccess", "Keine Berechtigung.")) + return@Column + } + TabRow( + selectedTabIndex = mainTab.ordinal, + backgroundColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + ) { + ClubSettingsWebTab.values().forEach { tab -> + Tab( + selected = mainTab == tab, + onClick = { mainTab = tab }, + text = { + Text( + when (tab) { + ClubSettingsWebTab.Settings -> tr("clubSettings.settings", "Einstellungen") + ClubSettingsWebTab.TrainingGroups -> tr("clubSettings.trainingGroups", "Trainingsgruppen") + ClubSettingsWebTab.TrainingTimes -> tr("clubSettings.trainingTimes", "Trainingszeiten") + }, + style = MaterialTheme.typography.body2, + maxLines = 2, + ) + }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + when (mainTab) { + ClubSettingsWebTab.Settings -> { + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + loadError != null -> Text(loadError!!, color = MaterialTheme.colors.error) + club == null -> Text(tr("clubSettings.loadFailed", "Einstellungen konnten nicht geladen werden")) + else -> Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = ClubSettingsCardElev, + ) { + Column(Modifier.padding(16.dp)) { + Text(tr("clubSettings.greetingText", "Begrüßungstext"), fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = greeting, + onValueChange = { greeting = it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + minLines = 6, + label = { Text(tr("clubSettings.greetingPlaceholder", "Begrüßungstext für Heimspiele...")) }, + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + elevation = 0.dp, + backgroundColor = MaterialTheme.colors.background, + ) { + Column(Modifier.padding(10.dp)) { + Text( + tr("clubSettings.placeholders", "Platzhalter"), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.subtitle2, + ) + Spacer(Modifier.height(6.dp)) + Text( + "{home} — ${tr("clubSettings.homeTeam", "Name Heimmannschaft")}", + style = MaterialTheme.typography.caption, + fontFamily = FontFamily.Monospace, + ) + Text( + "{guest} — ${tr("clubSettings.guestTeam", "Name Gastmannschaft")}", + style = MaterialTheme.typography.caption, + fontFamily = FontFamily.Monospace, + ) + Text( + "{homeplayers} — ${tr("clubSettings.homePlayers", "Spieler und Doppel Heimmannschaft")}", + style = MaterialTheme.typography.caption, + fontFamily = FontFamily.Monospace, + ) + Text( + "{guestplayers} — ${tr("clubSettings.guestPlayers", "Spieler und Doppel Gastmannschaft")}", + style = MaterialTheme.typography.caption, + fontFamily = FontFamily.Monospace, + ) + } + } + Text( + tr("clubSettings.greetingHint", "Dieser Text erscheint im Reiter \"Begrüßung\" des Spielberichtsbogens."), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + + Spacer(Modifier.height(12.dp)) + Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { + Column(Modifier.padding(16.dp)) { + Text(tr("clubSettings.associationMemberNumber", "Verbands-Mitgliedsnummer"), fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = associationNumber, + onValueChange = { associationNumber = it }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + placeholder = { Text(tr("clubSettings.associationMemberNumberPlaceholder", "z. B. 12-3456")) }, + ) + } + } + + Spacer(Modifier.height(12.dp)) + Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { + Column(Modifier.padding(16.dp)) { + Text( + tr("clubSettings.calendarRegion", "Kalenderregion"), + fontWeight = FontWeight.SemiBold, + ) + Text( + tr( + "clubSettings.calendarRegionHint", + "Die Region wird für Schulferien und gesetzliche Feiertage im Kalender genutzt.", + ), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + tr("clubSettings.country", "Land"), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 8.dp), + ) + Box { + TextButton(onClick = { /* nur DE wie Web */ }, enabled = false, modifier = Modifier.fillMaxWidth()) { + Text(tr("clubSettings.countryGermany", "Deutschland")) + } + } + Text( + tr("clubSettings.state", "Bundesland"), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 8.dp), + ) + Box { + TextButton(onClick = { stateMenu = true }, modifier = Modifier.fillMaxWidth()) { + Text( + germanStatesClubSettings.find { it.code == stateCode }?.name + ?: tr("mobile.stateNotSet", "Nicht gesetzt"), + ) + } + DropdownMenu(expanded = stateMenu, onDismissRequest = { stateMenu = false }) { + DropdownMenuItem( + onClick = { + stateCode = "" + stateMenu = false + }, + ) { Text(tr("mobile.stateNotSet", "Nicht gesetzt")) } + germanStatesClubSettings.forEach { s -> + DropdownMenuItem( + onClick = { + stateCode = s.code + stateMenu = false + }, + ) { Text(s.name) } + } + } + } + } + } + + Spacer(Modifier.height(12.dp)) + Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { + Column(Modifier.padding(16.dp)) { + Text(tr("clubSettings.myTischtennisRankings", "myTischtennis TTR/QTTR-Ranglisten"), fontWeight = FontWeight.SemiBold) + Text( + tr( + "clubSettings.myTischtennisRankingsHint", + "Automatischer Abruf der Vereins-Rangliste für TTR- und QTTR-Updates der Mitglieder.", + ), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 4.dp), + ) + RowSwitchClubSettings(tr("clubSettings.autoFetchRankings", "Ranglisten automatisch abrufen"), autoFetch) { + autoFetch = it + } + if (autoFetch) { + OutlinedTextField( + value = myTtNickname, + onValueChange = { myTtNickname = it }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + label = { Text(tr("clubSettings.myTischtennisFedNickname", "Verbandskürzel")) }, + placeholder = { Text(tr("clubSettings.myTischtennisFedNicknamePlaceholder", "z. B. HeTTV")) }, + singleLine = true, + ) + Text( + tr( + "clubSettings.rankingsUsesAssociationNumber", + "Die Vereinsnummer für den Ranglisten-Abruf entspricht der Verbands-Mitgliedsnummer oben.", + ), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { + Column(Modifier.padding(16.dp)) { + Text(tr("clubSettings.memberDataQuality", "Datenqualität Mitglieder"), fontWeight = FontWeight.SemiBold) + Text( + tr( + "clubSettings.memberDataQualityHint", + "Diese Felder zählen auf der Mitgliederseite als nötig. Alle Felder bleiben weiterhin eingebbar.", + ), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + ) + RowSwitchClubSettings(tr("clubSettings.requireStreet", "Straße nötig"), quality.requireStreet) { + quality = quality.copy(requireStreet = it) + } + RowSwitchClubSettings(tr("clubSettings.requirePostalCode", "PLZ nötig"), quality.requirePostalCode) { + quality = quality.copy(requirePostalCode = it) + } + RowSwitchClubSettings(tr("clubSettings.requireCity", "Ort nötig"), quality.requireCity) { + quality = quality.copy(requireCity = it) + } + RowSwitchClubSettings(tr("clubSettings.requirePhone", "Telefonnummer nötig"), quality.requirePhone) { + quality = quality.copy(requirePhone = it) + } + RowSwitchClubSettings(tr("clubSettings.requireEmail", "E-Mail-Adresse nötig"), quality.requireEmail) { + quality = quality.copy(requireEmail = it) + } + } + } + + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { + if (perms.canWriteClubSettings()) { + scope.launch { + saving = true + savedHint = false + runCatching { + dependencies.clubManager.updateClubSettings( + clubId, + UpdateClubSettingsBody( + greetingText = greeting, + associationMemberNumber = associationNumber, + countryCode = countryCode, + stateCode = stateCode.ifBlank { null }, + myTischtennisFedNickname = myTtNickname.ifBlank { null }, + autoFetchRankings = autoFetch, + memberDataQualityRequirements = normalizeQuality(quality), + ), + ) + club = dependencies.clubManager.fetchClubDetail(clubId) + savedHint = true + }.onFailure { loadError = it.message } + saving = false + } + } + }, + enabled = !saving && perms.canWriteClubSettings(), + modifier = Modifier.heightIn(min = ClubSettingsTouchMin), + ) { + Text(tr("clubSettings.save", "Speichern")) + } + if (savedHint) { + Text( + tr("clubSettings.saved", "Gespeichert"), + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + ClubSettingsWebTab.TrainingGroups -> ClubTrainingGroupsTabContent( + dependencies = dependencies, + clubId = clubId, + canWrite = perms.canWriteClubSettings(), + canReadMembers = perms.canReadMembers(), + ) + ClubSettingsWebTab.TrainingTimes -> ClubTrainingTimesTabContent( + dependencies = dependencies, + clubId = clubId, + canWrite = perms.canWriteClubSettings(), + ) + } + } +} + +@Composable +@Composable +private fun ClubTrainingGroupsTabContent( + dependencies: AppDependencies, + clubId: Int, + canWrite: Boolean, + canReadMembers: Boolean, +) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val scope = rememberCoroutineScope() + var groups by remember { mutableStateOf>(emptyList()) } + var members by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var refresh by remember { mutableIntStateOf(0) } + var showAdd by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + var editing by remember { mutableStateOf(null) } + var editName by remember { mutableStateOf("") } + var deleteTarget by remember { mutableStateOf(null) } + var memberPickGroupId by remember { mutableStateOf(null) } + var memberMenu by remember { mutableStateOf(false) } + + LaunchedEffect(clubId, refresh) { + loading = true + error = null + runCatching { + groups = sortedTrainingGroupsForClubSettings(dependencies.membersManager.listTrainingGroups(clubId)) + members = if (canReadMembers) { + dependencies.membersManager.listMembersForClub(clubId, activeOnly = true) + } else { + emptyList() + } + }.onFailure { error = it.message } + loading = false + } + + fun availableForGroup(gid: Int): List { + val g = groups.find { it.id == gid } ?: return members + val inGroup = g.members.map { it.id }.toSet() + return members.filter { it.id !in inGroup } + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("trainingGroupsTab.groups", "Gruppen"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold) + if (canWrite) { + TextButton(onClick = { showAdd = !showAdd }) { + Text(tr("trainingGroupsTab.newGroup", "+ Neue Gruppe")) + } + } + } + if (showAdd && canWrite) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text(tr("trainingGroupsTab.groupName", "Gruppenname")) }, + ) + Button( + onClick = { + scope.launch { + runCatching { + dependencies.membersManager.createClubTrainingGroup(clubId, newName) + newName = "" + showAdd = false + refresh++ + }.onFailure { error = it.message } + } + }, + enabled = newName.isNotBlank(), + ) { Text(tr("trainingGroupsTab.create", "Erstellen")) } + OutlinedButton(onClick = { showAdd = false; newName = "" }) { + Text(tr("trainingGroupsTab.cancel", "Abbrechen")) + } + } + } + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + error != null -> Text(error!!, color = MaterialTheme.colors.error) + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(groups, key = { it.id }) { g -> + Card( + modifier = Modifier.fillMaxWidth(), + elevation = ClubSettingsCardElev, + backgroundColor = if (g.isPreset) { + MaterialTheme.colors.primary.copy(alpha = 0.06f) + } else { + MaterialTheme.colors.surface + }, + ) { + Column(Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(g.name, fontWeight = FontWeight.SemiBold) + if (canWrite && !g.isPreset) { + Row { + TextButton(onClick = { editing = g; editName = g.name }) { + Text(tr("trainingGroupsTab.edit", "Bearbeiten")) + } + TextButton(onClick = { deleteTarget = g }) { + Text(tr("trainingGroupsTab.delete", "Löschen")) + } + } + } + } + Text( + "${g.members.size} ${tr("trainingGroupsTab.members", "Mitglieder")}", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 4.dp), + ) + if (g.members.isNotEmpty()) { + Column(Modifier.padding(top = 6.dp)) { + g.members.forEach { m -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(trainingGroupMemberLabel(m), modifier = Modifier.weight(1f)) + if (canWrite) { + TextButton( + onClick = { + scope.launch { + runCatching { + dependencies.membersManager.removeMemberFromTrainingGroup(clubId, g.id, m.id) + refresh++ + }.onFailure { error = it.message } + } + }, + ) { + Text("× ${tr("trainingGroupsTab.remove", "Entfernen")}") + } + } + } + } + } + } + if (canWrite && canReadMembers) { + Box(modifier = Modifier.padding(top = 8.dp)) { + TextButton( + onClick = { + memberPickGroupId = g.id + memberMenu = true + }, + enabled = availableForGroup(g.id).isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (availableForGroup(g.id).isEmpty()) { + tr("trainingGroupsTab.noMembersAvailable", "Keine Mitglieder verfügbar") + } else { + tr("trainingGroupsTab.addMember", "Mitglied hinzufügen...") + }, + ) + } + DropdownMenu( + expanded = memberMenu && memberPickGroupId == g.id, + onDismissRequest = { memberMenu = false; memberPickGroupId = null }, + ) { + availableForGroup(g.id).forEach { mem -> + DropdownMenuItem( + onClick = { + scope.launch { + runCatching { + dependencies.membersManager.addMemberToTrainingGroup(clubId, g.id, mem.id) + memberMenu = false + memberPickGroupId = null + refresh++ + }.onFailure { error = it.message } + } + }, + ) { + Text("${mem.firstName} ${mem.lastName}".trim()) + } + } + } + } + } + } + } + } + } + } + } + + if (editing != null) { + AlertDialog( + onDismissRequest = { editing = null }, + title = { Text(tr("trainingGroupsTab.editGroup", "Gruppe bearbeiten")) }, + text = { + OutlinedTextField( + value = editName, + onValueChange = { editName = it }, + singleLine = true, + placeholder = { Text(tr("trainingGroupsTab.groupName", "Gruppenname")) }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { + val eg = editing ?: return@TextButton + scope.launch { + runCatching { + dependencies.membersManager.updateClubTrainingGroup(clubId, eg.id, editName, eg.sortOrder) + editing = null + refresh++ + }.onFailure { error = it.message } + } + }, + enabled = editName.isNotBlank(), + ) { Text(tr("common.save", "Speichern")) } + }, + dismissButton = { + TextButton(onClick = { editing = null }) { Text(tr("trainingGroupsTab.cancel", "Abbrechen")) } + }, + ) + } + + if (deleteTarget != null) { + val dg = deleteTarget!! + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(tr("trainingGroupsTab.delete", "Löschen")) }, + text = { + Text( + "„${dg.name}“ löschen? Zuordnungen werden entfernt.", + ) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + runCatching { + dependencies.membersManager.deleteClubTrainingGroup(clubId, dg.id) + deleteTarget = null + refresh++ + }.onFailure { error = it.message } + } + }, + ) { Text(tr("trainingGroupsTab.delete", "Löschen")) } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { Text(tr("trainingGroupsTab.cancel", "Abbrechen")) } + }, + ) + } +} + +@Composable +private fun ClubTrainingTimesTabContent( + dependencies: AppDependencies, + clubId: Int, + canWrite: Boolean, +) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val scope = rememberCoroutineScope() + var groups by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var refresh by remember { mutableIntStateOf(0) } + var addForGroupId by remember { mutableStateOf(null) } + var newWeekday by remember { mutableIntStateOf(1) } + var newStart by remember { mutableStateOf("") } + var newEnd by remember { mutableStateOf("") } + var weekdayMenuNew by remember { mutableStateOf(false) } + var editing by remember { mutableStateOf(null) } + var editWeekday by remember { mutableIntStateOf(1) } + var editStart by remember { mutableStateOf("") } + var editEnd by remember { mutableStateOf("") } + var weekdayMenuEdit by remember { mutableStateOf(false) } + var deleteTimeId by remember { mutableStateOf(null) } + + LaunchedEffect(clubId, refresh) { + loading = true + error = null + runCatching { + groups = dependencies.membersManager.trainingScheduleGroups(clubId) + }.onFailure { error = it.message } + loading = false + } + + if (loading) { + Text(tr("trainingTimesTab.loading", "Lade Trainingszeiten..."), modifier = Modifier.padding(top = 24.dp)) + return + } + if (error != null) { + Text(error!!, color = MaterialTheme.colors.error) + return + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(groups, key = { it.id }) { g -> + Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { + Column(Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(g.name, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold) + if (canWrite) { + TextButton(onClick = { addForGroupId = g.id; newWeekday = 1; newStart = ""; newEnd = "" }) { + Text(tr("trainingTimesTab.addTime", "+ Zeit hinzufügen")) + } + } + } + if (addForGroupId == g.id && canWrite) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text(tr("trainingTimesTab.weekday", "Wochentag:"), style = MaterialTheme.typography.caption) + Box { + TextButton(onClick = { weekdayMenuNew = true }) { + Text(trainingWeekdayLabel(tr, newWeekday)) + } + DropdownMenu(expanded = weekdayMenuNew, onDismissRequest = { weekdayMenuNew = false }) { + (0..6).forEach { d -> + DropdownMenuItem( + onClick = { + newWeekday = d + weekdayMenuNew = false + }, + ) { Text(trainingWeekdayLabel(tr, d)) } + } + } + } + OutlinedTextField( + value = newStart, + onValueChange = { newStart = it }, + label = { Text(tr("trainingTimesTab.from", "Von:")) }, + placeholder = { Text("08:00") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + OutlinedTextField( + value = newEnd, + onValueChange = { newEnd = it }, + label = { Text(tr("trainingTimesTab.to", "Bis:")) }, + placeholder = { Text("10:00") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { + val s = parseClockToApi(newStart) ?: return@Button + val e = parseClockToApi(newEnd) ?: return@Button + if (s >= e) { + error = tr("trainingTimesTab.saveError", "Ungültige Zeitspanne") + return@Button + } + scope.launch { + runCatching { + dependencies.membersManager.createClubTrainingTime( + clubId, + CreateTrainingTimeBody( + trainingGroupId = g.id, + weekday = newWeekday, + startTime = s, + endTime = e, + ), + ) + addForGroupId = null + refresh++ + error = null + }.onFailure { error = it.message } + } + }, + ) { Text(tr("trainingTimesTab.create", "Erstellen")) } + OutlinedButton(onClick = { addForGroupId = null }) { + Text(tr("trainingTimesTab.cancel", "Abbrechen")) + } + } + } + } + val times = g.trainingTimes.sortedWith(compareBy({ it.weekday }, { it.startTime })) + if (times.isEmpty()) { + Text(tr("trainingTimesTab.noTimes", "Keine Trainingszeiten definiert"), modifier = Modifier.padding(top = 8.dp)) + } else { + Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + times.forEach { t -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(trainingWeekdayLabel(tr, t.weekday), fontWeight = FontWeight.Medium) + Text("${formatTimeDisplay(t.startTime)} – ${formatTimeDisplay(t.endTime)}") + } + if (canWrite) { + Row { + TextButton(onClick = { + editing = t + editWeekday = t.weekday + editStart = formatTimeDisplay(t.startTime) + editEnd = formatTimeDisplay(t.endTime) + }) { + Text(tr("trainingTimesTab.edit", "Bearbeiten")) + } + TextButton(onClick = { deleteTimeId = t.id }) { + Text(tr("trainingTimesTab.delete", "Löschen")) + } + } + } + } + } + } + } + } + } + } + } + + if (editing != null) { + val et = editing!! + AlertDialog( + onDismissRequest = { editing = null }, + title = { Text(tr("trainingTimesTab.editTime", "Trainingszeit bearbeiten")) }, + text = { + Column { + Text(tr("trainingTimesTab.weekday", "Wochentag:"), style = MaterialTheme.typography.caption) + Box { + TextButton(onClick = { weekdayMenuEdit = true }) { + Text(trainingWeekdayLabel(tr, editWeekday)) + } + DropdownMenu(expanded = weekdayMenuEdit, onDismissRequest = { weekdayMenuEdit = false }) { + (0..6).forEach { d -> + DropdownMenuItem( + onClick = { + editWeekday = d + weekdayMenuEdit = false + }, + ) { Text(trainingWeekdayLabel(tr, d)) } + } + } + } + OutlinedTextField( + value = editStart, + onValueChange = { editStart = it }, + label = { Text(tr("trainingTimesTab.from", "Von:")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = editEnd, + onValueChange = { editEnd = it }, + label = { Text(tr("trainingTimesTab.to", "Bis:")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val s = parseClockToApi(editStart) ?: return@TextButton + val e = parseClockToApi(editEnd) ?: return@TextButton + if (s >= e) return@TextButton + scope.launch { + runCatching { + dependencies.membersManager.updateClubTrainingTime( + clubId, + et.id, + UpdateTrainingTimeBody(weekday = editWeekday, startTime = s, endTime = e), + ) + editing = null + refresh++ + }.onFailure { error = it.message } + } + }, + ) { Text(tr("trainingTimesTab.save", "Speichern")) } + }, + dismissButton = { + TextButton(onClick = { editing = null }) { Text(tr("trainingTimesTab.cancel", "Abbrechen")) } + }, + ) + } + + if (deleteTimeId != null) { + val tid = deleteTimeId!! + AlertDialog( + onDismissRequest = { deleteTimeId = null }, + title = { Text(tr("trainingTimesTab.delete", "Löschen")) }, + text = { Text("Diese Trainingszeit wirklich löschen?") }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + runCatching { + dependencies.membersManager.deleteClubTrainingTime(clubId, tid) + deleteTimeId = null + refresh++ + }.onFailure { error = it.message } + } + }, + ) { Text(tr("trainingTimesTab.delete", "Löschen")) } + }, + dismissButton = { + TextButton(onClick = { deleteTimeId = null }) { Text(tr("trainingTimesTab.cancel", "Abbrechen")) } + }, + ) + } +} 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 c75da26f..e9750de3 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 @@ -5,7 +5,6 @@ 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -46,16 +45,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.tt_tagebuch.app.AppDependencies -import de.tt_tagebuch.shared.api.models.Club -import de.tt_tagebuch.shared.api.models.MemberDataQualityRequirements +import de.tt_tagebuch.shared.api.PredefinedActivitiesApi import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope +import de.tt_tagebuch.shared.api.models.MemberTransferConfigSaveBody import de.tt_tagebuch.shared.api.models.PredefinedActivityDto import de.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody +import de.tt_tagebuch.shared.api.models.TrainingGroupDto +import de.tt_tagebuch.shared.api.models.TrainingTimeDto import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody +import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody import de.tt_tagebuch.shared.api.models.canReadClubSettings import de.tt_tagebuch.shared.api.models.canReadMembers import de.tt_tagebuch.shared.api.models.canReadPredefinedActivities -import de.tt_tagebuch.shared.api.models.canWriteClubSettings import de.tt_tagebuch.shared.api.models.canWriteMembers import de.tt_tagebuch.shared.api.models.canWritePredefinedActivities import de.tt_tagebuch.shared.api.models.displayLabel @@ -67,27 +68,6 @@ import kotlinx.serialization.json.buildJsonObject private val StammdatenPad = 20.dp private val StammdatenTouchMin = 48.dp -private data class GermanStateOption(val code: String, val name: String) - -private val germanStates: List = listOf( - GermanStateOption("DE-BW", "Baden-Württemberg"), - GermanStateOption("DE-BY", "Bayern"), - GermanStateOption("DE-BE", "Berlin"), - GermanStateOption("DE-BB", "Brandenburg"), - GermanStateOption("DE-HB", "Bremen"), - GermanStateOption("DE-HH", "Hamburg"), - GermanStateOption("DE-HE", "Hessen"), - GermanStateOption("DE-MV", "Mecklenburg-Vorpommern"), - GermanStateOption("DE-NI", "Niedersachsen"), - GermanStateOption("DE-NW", "Nordrhein-Westfalen"), - GermanStateOption("DE-RP", "Rheinland-Pfalz"), - GermanStateOption("DE-SL", "Saarland"), - GermanStateOption("DE-SN", "Sachsen"), - GermanStateOption("DE-ST", "Sachsen-Anhalt"), - GermanStateOption("DE-SH", "Schleswig-Holstein"), - GermanStateOption("DE-TH", "Thüringen"), -) - internal enum class ClubStammdatenSection { ClubSettings, PredefinedActivities, @@ -122,227 +102,6 @@ private fun StammdatenTopBar(title: String, onBack: () -> Unit) { Spacer(modifier = Modifier.height(8.dp)) } -private fun defaultQuality(): MemberDataQualityRequirements = MemberDataQualityRequirements() - -private fun normalizeQuality(m: MemberDataQualityRequirements?): MemberDataQualityRequirements { - val d = defaultQuality() - if (m == null) return d - return MemberDataQualityRequirements( - requireStreet = m.requireStreet, - requirePostalCode = m.requirePostalCode, - requireCity = m.requireCity, - requirePhone = m.requirePhone, - requireEmail = m.requireEmail, - ) -} - -@Composable -private fun MobileClubSettingsScreen(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 scope = rememberCoroutineScope() - var club by remember { mutableStateOf(null) } - var loadError by remember { mutableStateOf(null) } - var loading by remember { mutableStateOf(true) } - var saving by remember { mutableStateOf(false) } - var savedHint by remember { mutableStateOf(false) } - - var greeting by remember { mutableStateOf("") } - var associationNumber by remember { mutableStateOf("") } - var countryCode by remember { mutableStateOf("DE") } - var stateCode by remember { mutableStateOf("") } - var stateMenu by remember { mutableStateOf(false) } - var myTtNickname by remember { mutableStateOf("") } - var autoFetch by remember { mutableStateOf(false) } - var quality by remember { mutableStateOf(defaultQuality()) } - - LaunchedEffect(clubId) { - loading = true - loadError = null - club = runCatching { dependencies.clubManager.fetchClubDetail(clubId) } - .onFailure { loadError = it.message } - .getOrNull() - val c = club - if (c != null) { - greeting = c.greetingText.orEmpty() - associationNumber = c.associationMemberNumber.orEmpty() - countryCode = c.countryCode?.ifBlank { "DE" } ?: "DE" - stateCode = c.stateCode.orEmpty() - myTtNickname = c.myTischtennisFedNickname.orEmpty() - autoFetch = c.autoFetchRankings == true - quality = normalizeQuality(c.memberDataQualityRequirements) - } - loading = false - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(StammdatenPad), - ) { - StammdatenTopBar(tr("clubSettings.title", "Vereinseinstellungen"), onBack) - if (perms?.canReadClubSettings() != true) { - Text(tr("mobile.noAccess", "Keine Berechtigung.")) - return@Column - } - when { - loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) - loadError != null -> Text(loadError!!, color = MaterialTheme.colors.error) - club == null -> Text(tr("clubSettings.loadFailed", "Laden fehlgeschlagen")) - else -> { - Text(tr("clubSettings.greetingText", "Begrüßung"), fontWeight = FontWeight.SemiBold) - OutlinedTextField( - value = greeting, - onValueChange = { greeting = it }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - minLines = 4, - label = { Text(tr("clubSettings.greetingPlaceholder", "Text")) }, - ) - Text( - tr("clubSettings.greetingHint", "Platzhalter: {home}, {guest}, …"), - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(top = 4.dp), - ) - - Spacer(modifier = Modifier.height(16.dp)) - Text(tr("clubSettings.associationMemberNumber", "Verbands-Nr."), fontWeight = FontWeight.SemiBold) - OutlinedTextField( - value = associationNumber, - onValueChange = { associationNumber = it }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(16.dp)) - Text("Kalenderregion", fontWeight = FontWeight.SemiBold) - Text("Land", style = MaterialTheme.typography.caption) - OutlinedTextField( - value = countryCode, - onValueChange = { countryCode = it.uppercase().take(2) }, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), - singleLine = true, - enabled = false, - ) - Text("Bundesland", style = MaterialTheme.typography.caption, modifier = Modifier.padding(top = 8.dp)) - Box { - TextButton(onClick = { stateMenu = true }, modifier = Modifier.fillMaxWidth()) { - Text( - germanStates.find { it.code == stateCode }?.name - ?: tr("mobile.stateNotSet", "Nicht gesetzt"), - ) - } - DropdownMenu(expanded = stateMenu, onDismissRequest = { stateMenu = false }) { - DropdownMenuItem( - onClick = { - stateCode = "" - stateMenu = false - }, - ) { Text(tr("mobile.stateNotSet", "Nicht gesetzt")) } - germanStates.forEach { s -> - DropdownMenuItem( - onClick = { - stateCode = s.code - stateMenu = false - }, - ) { Text(s.name) } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - Text(tr("clubSettings.myTischtennisRankings", "MyTischtennis-Rankings"), fontWeight = FontWeight.SemiBold) - RowSwitch(tr("clubSettings.autoFetchRankings", "Automatisch abrufen"), autoFetch) { autoFetch = it } - if (autoFetch) { - OutlinedTextField( - value = myTtNickname, - onValueChange = { myTtNickname = it }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - label = { Text(tr("clubSettings.myTischtennisFedNickname", "Verbands-Kurzname")) }, - singleLine = true, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Text(tr("clubSettings.memberDataQuality", "Pflichtfelder Mitglieder"), fontWeight = FontWeight.SemiBold) - RowSwitch(tr("clubSettings.requireStreet", "Straße"), quality.requireStreet) { - quality = quality.copy(requireStreet = it) - } - RowSwitch(tr("clubSettings.requirePostalCode", "PLZ"), quality.requirePostalCode) { - quality = quality.copy(requirePostalCode = it) - } - RowSwitch(tr("clubSettings.requireCity", "Ort"), quality.requireCity) { - quality = quality.copy(requireCity = it) - } - RowSwitch(tr("clubSettings.requirePhone", "Telefon"), quality.requirePhone) { - quality = quality.copy(requirePhone = it) - } - RowSwitch(tr("clubSettings.requireEmail", "E-Mail"), quality.requireEmail) { - quality = quality.copy(requireEmail = it) - } - - TextButton( - onClick = { dependencies.openBackendPath("/club-settings") }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .heightIn(min = StammdatenTouchMin), - ) { - Text(tr("mobile.openTrainingGroupsWeb", "Trainingsgruppen & Zeiten im Browser")) - } - - if (savedHint) { - Text( - tr("clubSettings.saved", "Gespeichert"), - color = MaterialTheme.colors.primary, - modifier = Modifier.padding(top = 8.dp), - ) - } - - Button( - onClick = { - if (perms.canWriteClubSettings()) { - scope.launch { - saving = true - savedHint = false - runCatching { - dependencies.clubManager.updateClubSettings( - clubId, - UpdateClubSettingsBody( - greetingText = greeting, - associationMemberNumber = associationNumber, - countryCode = countryCode, - stateCode = stateCode.ifBlank { null }, - myTischtennisFedNickname = myTtNickname.ifBlank { null }, - autoFetchRankings = autoFetch, - memberDataQualityRequirements = normalizeQuality(quality), - ), - ) - club = dependencies.clubManager.fetchClubDetail(clubId) - savedHint = true - }.onFailure { loadError = it.message } - saving = false - } - } - }, - enabled = !saving && perms.canWriteClubSettings(), - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .heightIn(min = StammdatenTouchMin), - ) { - Text(tr("clubSettings.save", "Speichern")) - } - } - } - } -} - @Composable private fun MobilePredefinedActivitiesScreen(dependencies: AppDependencies, onBack: () -> Unit) { val languageCode = LocalLanguageCode.current @@ -431,7 +190,6 @@ private fun MobilePredefinedActivitiesScreen(dependencies: AppDependencies, onBa } } -@Composable @Composable private fun PredefinedActivityEditorDialog( initial: PredefinedActivityDto?, @@ -441,7 +199,7 @@ private fun PredefinedActivityEditorDialog( onSaved: () -> Unit, resolve: (String, String) -> String, scope: kotlinx.coroutines.CoroutineScope, - api: de.tt_tagebuch.shared.api.PredefinedActivitiesApi, + api: PredefinedActivitiesApi, ) { var name by remember(initial?.id, isNew) { mutableStateOf(initial?.name.orEmpty()) } var code by remember(initial?.id, isNew) { mutableStateOf(initial?.code.orEmpty()) } @@ -707,3 +465,17 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: () } } } + +@Composable +private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChecked) + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingGroupsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingGroupsApi.kt index 9ec79ea5..59bf13f5 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingGroupsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingGroupsApi.kt @@ -1,11 +1,14 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.CreateClubTrainingGroupBody import de.tt_tagebuch.shared.api.models.TrainingGroupDto +import de.tt_tagebuch.shared.api.models.UpdateClubTrainingGroupBody import io.ktor.client.call.body import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType @@ -31,4 +34,22 @@ class TrainingGroupsApi( suspend fun removeMemberFromGroup(clubId: Int, groupId: Int, memberId: Int) { client.http.delete("/api/training-groups/$clubId/$groupId/member/$memberId") } + + suspend fun createGroup(clubId: Int, name: String): TrainingGroupDto { + return client.http.post("/api/training-groups/$clubId") { + contentType(ContentType.Application.Json) + setBody(CreateClubTrainingGroupBody(name = name.trim())) + }.body() + } + + suspend fun updateGroup(clubId: Int, groupId: Int, name: String, sortOrder: Int?): TrainingGroupDto { + return client.http.put("/api/training-groups/$clubId/$groupId") { + contentType(ContentType.Application.Json) + setBody(UpdateClubTrainingGroupBody(name = name.trim(), sortOrder = sortOrder)) + }.body() + } + + suspend fun deleteGroup(clubId: Int, groupId: Int) { + client.http.delete("/api/training-groups/$clubId/$groupId") + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingTimesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingTimesApi.kt index 000b66f4..bba1b851 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingTimesApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TrainingTimesApi.kt @@ -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.CreateTrainingTimeBody import de.tt_tagebuch.shared.api.models.TrainingGroupDto +import de.tt_tagebuch.shared.api.models.TrainingTimeDto +import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType class TrainingTimesApi( private val client: AuthedHttpClient, @@ -11,4 +20,22 @@ class TrainingTimesApi( suspend fun listGroupsWithTimes(clubId: Int): List { return client.http.get("/api/training-times/$clubId").body() } + + suspend fun createTime(clubId: Int, body: CreateTrainingTimeBody): TrainingTimeDto { + return client.http.post("/api/training-times/$clubId") { + contentType(ContentType.Application.Json) + setBody(body) + }.body() + } + + suspend fun updateTime(clubId: Int, timeId: Int, body: UpdateTrainingTimeBody): TrainingTimeDto { + return client.http.put("/api/training-times/$clubId/$timeId") { + contentType(ContentType.Application.Json) + setBody(body) + }.body() + } + + suspend fun deleteTime(clubId: Int, timeId: Int) { + client.http.delete("/api/training-times/$clubId/$timeId") + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TrainingGroupDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TrainingGroupDtos.kt index 586f2db1..e0e09ab6 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TrainingGroupDtos.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TrainingGroupDtos.kt @@ -12,6 +12,13 @@ data class TrainingTimeDto( val sortOrder: Int = 0, ) +@Serializable +data class TrainingGroupMemberBrief( + val id: Int, + val firstName: String? = null, + val lastName: String? = null, +) + @Serializable data class TrainingGroupDto( val id: Int = 0, @@ -21,4 +28,32 @@ data class TrainingGroupDto( val isPreset: Boolean = false, val presetType: String? = null, val trainingTimes: List = emptyList(), + val members: List = emptyList(), +) + +@Serializable +data class CreateClubTrainingGroupBody( + val name: String, + val sortOrder: Int? = null, +) + +@Serializable +data class UpdateClubTrainingGroupBody( + val name: String, + val sortOrder: Int? = null, +) + +@Serializable +data class CreateTrainingTimeBody( + val trainingGroupId: Int, + val weekday: Int, + val startTime: String, + val endTime: String, +) + +@Serializable +data class UpdateTrainingTimeBody( + val weekday: Int, + val startTime: String, + val endTime: String, ) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt index 8f2931a9..b5c8d1d1 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt @@ -4,11 +4,14 @@ 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.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.MemberLastParticipationDto import de.tt_tagebuch.shared.api.models.MemberSetBody import de.tt_tagebuch.shared.api.models.TrainingGroupDto +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 @@ -43,6 +46,20 @@ class MembersManager( suspend fun listTrainingGroups(clubId: Int): List = trainingGroupsApi.listGroups(clubId) + suspend fun listMembersForClub(clubId: Int, activeOnly: Boolean = true): List = + membersApi.listMembers(clubId, showAll = !activeOnly) + .sortedWith(compareBy { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() }) + + suspend fun createClubTrainingGroup(clubId: Int, name: String): TrainingGroupDto = + trainingGroupsApi.createGroup(clubId, name) + + suspend fun updateClubTrainingGroup(clubId: Int, groupId: Int, name: String, sortOrder: Int?): TrainingGroupDto = + trainingGroupsApi.updateGroup(clubId, groupId, name, sortOrder) + + suspend fun deleteClubTrainingGroup(clubId: Int, groupId: Int) { + trainingGroupsApi.deleteGroup(clubId, groupId) + } + suspend fun listMemberTrainingGroups(clubId: Int, memberId: Int): List = trainingGroupsApi.listMemberGroups(clubId, memberId) @@ -54,6 +71,16 @@ class MembersManager( trainingGroupsApi.removeMemberFromGroup(clubId, groupId, memberId) } + suspend fun createClubTrainingTime(clubId: Int, body: CreateTrainingTimeBody): TrainingTimeDto = + trainingTimesApi.createTime(clubId, body) + + suspend fun updateClubTrainingTime(clubId: Int, timeId: Int, body: UpdateTrainingTimeBody): TrainingTimeDto = + trainingTimesApi.updateTime(clubId, timeId, body) + + suspend fun deleteClubTrainingTime(clubId: Int, timeId: Int) { + trainingTimesApi.deleteTime(clubId, timeId) + } + suspend fun memberActivityStats(clubId: Int, memberId: Int, period: String = "year"): List = memberActivitiesApi.listActivityStats(clubId, memberId, period)