From 57468f1efbc3647923facc33d662a1057aa59fe3 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 12 May 2026 23:52:54 +0200 Subject: [PATCH] feat(Tournament): add official tournament participation feature - Introduced functionality to load and display official tournament participation events in the CalendarView. - Updated the API client to fetch official tournament data, enhancing the event management capabilities. - Added new UI elements to represent official tournaments, including visual indicators and event details. - Enhanced the ClubManager to support fetching and managing official tournament data. - Updated the mobile app's TODO list to reflect progress on tournament-related features. --- backend/services/tournamentService.js | 2 +- frontend/src/views/CalendarView.vue | 38 +- mobile-app/TODO.md | 8 +- .../de/tt_tagebuch/app/AppDependencies.kt | 6 +- .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 14 +- .../app/ui/ClubStammdatenScreens.kt | 709 ++++++++++++++++++ .../tt_tagebuch/app/ui/TournamentsScreen.kt | 1 - .../de/tt_tagebuch/shared/api/ClubsApi.kt | 8 + .../shared/api/MemberTransferConfigApi.kt | 33 + .../shared/api/PredefinedActivitiesApi.kt | 16 + .../de/tt_tagebuch/shared/api/models/Club.kt | 23 + .../api/models/ClubPermissionHelpers.kt | 20 + .../shared/api/models/MemberTransferDtos.kt | 47 ++ .../api/models/PredefinedActivityDtos.kt | 12 + .../tt_tagebuch/shared/state/ClubManager.kt | 5 + 15 files changed, 931 insertions(+), 11 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberTransferConfigApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index f3c339e8..df5627ad 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -1008,7 +1008,7 @@ class TournamentService { return JSON.parse(JSON.stringify(tournaments)); } -Ve // 2. Neues Turnier anlegen + // 2. Neues Turnier anlegen async addTournament(userToken, clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament = false) { await checkAccess(userToken, clubId); const t = await Tournament.create({ diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index a4d6848c..2eaeda79 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -138,6 +138,7 @@ export default { activeTypes: { training: true, tournament: true, + officialTournament: true, match: true, holiday: true, schoolHoliday: true, @@ -160,6 +161,7 @@ export default { return [ { key: 'training', label: 'Training' }, { key: 'tournament', label: 'Turnier' }, + { key: 'officialTournament', label: 'Teilnahme' }, { key: 'match', label: 'Punktspiel' }, { key: 'holiday', label: 'Feiertag' }, { key: 'schoolHoliday', label: 'Ferien' }, @@ -188,7 +190,7 @@ export default { return this.events.reduce((counts, event) => { counts[event.type] = (counts[event.type] || 0) + 1; return counts; - }, { training: 0, tournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 }); + }, { training: 0, tournament: 0, officialTournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 }); }, visibleTrainingCancellations() { const month = this.cursor.getMonth(); @@ -253,6 +255,7 @@ export default { this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()), this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()), this.loadSource('Turniere', () => this.loadTournamentEvents()), + this.loadSource('Turnierteilnahmen', () => this.loadOfficialTournamentEvents()), this.loadSource('Punktspiele', () => this.loadMatchEvents()), this.loadSource('Ferien/Feiertage', () => this.loadHolidayEvents()) ]); @@ -435,6 +438,31 @@ export default { }; }); }, + async loadOfficialTournamentEvents() { + const response = await apiClient.get(`/official-tournaments/${this.currentClub}/participations/summary`); + this.ensureSuccess(response, 'Turnierteilnahmen'); + return (response.data || []) + .filter(tournament => Array.isArray(tournament.entries) && tournament.entries.length > 0) + .map(tournament => { + const date = this.parseDmyDate(tournament.startDate); + const endDate = this.parseDmyDate(tournament.endDate || tournament.startDate); + if (!date) return null; + const entries = tournament.entries || []; + const participantCount = new Set(entries.map(entry => entry.memberId).filter(Boolean)).size; + return { + id: `official-tournament-${tournament.tournamentId}`, + type: 'officialTournament', + date, + endDate: endDate || date, + startsAt: this.combineDateTime(date), + time: '', + title: tournament.tournamentName || tournament.title || 'Turnierteilnahme', + subtitle: participantCount > 0 ? `${participantCount} Teilnehmer` : `${entries.length} Starts`, + route: '/tournament-participations' + }; + }) + .filter(Boolean); + }, async loadHolidayEvents() { const response = await apiClient.get(`/calendar/club/${this.currentClub}/holidays`, { params: { year: this.displayedYear } @@ -483,6 +511,12 @@ export default { if (!year || !month || !day) return new Date(value); return new Date(year, month - 1, day); }, + parseDmyDate(value) { + const match = String(value || '').match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); + if (!match) return null; + const date = new Date(Number(match[3]), Number(match[2]) - 1, Number(match[1])); + return Number.isNaN(date.getTime()) ? null : date; + }, combineDateTime(date, time) { if (!date) return Number.POSITIVE_INFINITY; const result = new Date(date); @@ -659,6 +693,7 @@ export default { .legend-training::before { background: #2f7a5f; } .legend-tournament::before { background: #b7791f; } +.legend-officialTournament::before { background: #9333ea; } .legend-match::before { background: #2563eb; } .legend-holiday::before { background: #dc2626; } .legend-schoolHoliday::before { background: #7c3aed; } @@ -823,6 +858,7 @@ export default { .event-training { background: #e8f4ef; border-left: 4px solid #2f7a5f; } .event-tournament { background: #fff7e6; border-left: 4px solid #b7791f; } +.event-officialTournament { background: #f3e8ff; border-left: 4px solid #9333ea; } .event-match { background: #eaf1ff; border-left: 4px solid #2563eb; } .event-holiday { background: #fee2e2; border-left: 4px solid #dc2626; } .event-schoolHoliday { background: #f3e8ff; border-left: 4px solid #7c3aed; } diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 3b699aff..6c0256fc 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -154,10 +154,10 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien ## Phase 7 – Turniere -- [ ] `TournamentsView.vue` – Vereinsturniere -- [ ] `OfficialTournaments.vue` / offizielle Teilnahmen -- [ ] `TournamentTab.vue` – eingebettete Logik, soweit mobil relevant -- [ ] API aus jeweiligen Views dokumentieren und abarbeiten +- [x] **Vereins-Turniere** (`TournamentsView.vue` / `TournamentTab.vue`): `GET /api/tournament/:clubId` (optional `?type=mini`), clientseitig intern/offen wie Web; `GET /api/tournament/:clubId/:id` für Detail – UI `TournamentsScreen.kt`, `TournamentsApi`, `ClubInternalTournamentsManager` +- [x] **Offizielle Teilnahmen** (`OfficialTournaments.vue`): `GET /api/official-tournaments/:clubId`, `GET .../participations/summary` – `OfficialTournamentsApi`, `OfficialTournamentsReadManager`, gleicher Screen +- [x] **Navigation:** Tab **Turniere** (`MainTab.Tournaments`) bei `canReadTournaments()`, Start-Kachel; Deep-Link `/tournaments` für volle Web-Verwaltung +- [ ] **Nicht mobil:** PDF-Import, Meldungen/ClickTT-Workflows, Spielstände pflegen, Gruppen/K.O. wie Web – bewusst Web-only --- diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 0fbc0f93..54b698b1 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -20,6 +20,7 @@ import de.tt_tagebuch.shared.api.PredefinedActivitiesApi import de.tt_tagebuch.shared.api.MatchesApi import de.tt_tagebuch.shared.api.MemberActivitiesApi import de.tt_tagebuch.shared.api.MemberGroupPhotosApi +import de.tt_tagebuch.shared.api.MemberTransferConfigApi import de.tt_tagebuch.shared.api.MembersApi import de.tt_tagebuch.shared.api.OfficialTournamentsApi import de.tt_tagebuch.shared.api.PermissionsApi @@ -45,7 +46,6 @@ import de.tt_tagebuch.shared.state.MutableTokenProvider import de.tt_tagebuch.shared.state.OfficialTournamentsReadManager import de.tt_tagebuch.shared.state.PendingApprovalsManager import de.tt_tagebuch.shared.state.PermissionsAdminManager -import de.tt_tagebuch.shared.state.MutableTokenProvider import de.tt_tagebuch.shared.state.ScheduleManager import de.tt_tagebuch.shared.state.TrainingStatsManager import kotlinx.coroutines.CoroutineScope @@ -87,6 +87,7 @@ class AppDependencies(context: Context) { ) private val permissionsApi = PermissionsApi(client) + val predefinedActivitiesApi = PredefinedActivitiesApi(client) val clubManager = ClubManager( clubStorage = AndroidClubStorage(context.applicationContext), @@ -99,6 +100,7 @@ class AppDependencies(context: Context) { val apiLogsManager = ApiLogsManager(ApiLogsApi(client)) val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client)) val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client)) + val memberTransferConfigApi = MemberTransferConfigApi(client) val diaryManager = DiaryManager( DiaryApi(client), @@ -106,7 +108,7 @@ class AppDependencies(context: Context) { GroupApi(client), DiaryMemberActivitiesApi(client), DiaryMemberApi(client), - PredefinedActivitiesApi(client), + predefinedActivitiesApi, AccidentApi(client), MemberGroupPhotosApi(client), ) 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 533dbfeb..d633a565 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 @@ -211,8 +211,9 @@ private fun MainTabs(dependencies: AppDependencies) { val clubState by dependencies.clubManager.state.collectAsState() val visibleTabs = visibleMainTabs(clubState.currentPermissions) - LaunchedEffect(visibleTabs, selectedTab) { - if (!visibleTabs.contains(selectedTab)) { + LaunchedEffect(clubState.currentPermissions, selectedTab) { + val tabs = visibleMainTabs(clubState.currentPermissions) + if (!tabs.contains(selectedTab)) { selectedTab = MainTab.Home } } @@ -380,6 +381,13 @@ private fun HomeScreen( onClick = { onOpenTab(MainTab.Schedule) }, ) } + if (p.canReadTournaments()) { + HomeHubTile( + title = tr("navigation.clubTournaments", "Turniere"), + subtitle = tr("home.tileTournaments", "Vereins-Turniere, Teilnahmen"), + onClick = { onOpenTab(MainTab.Tournaments) }, + ) + } } HomeHubTile( title = tr("navigation.statistics", "Statistik"), @@ -4079,6 +4087,8 @@ private fun SettingsScreen(dependencies: AppDependencies) { dependencies.pendingApprovalsManager.clear() dependencies.permissionsAdminManager.clear() dependencies.apiLogsManager.clear() + dependencies.clubInternalTournamentsManager.clear() + dependencies.officialTournamentsReadManager.clear() } }, modifier = Modifier 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 new file mode 100644 index 00000000..c75da26f --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubStammdatenScreens.kt @@ -0,0 +1,709 @@ +package de.tt_tagebuch.app.ui + +import androidx.activity.compose.BackHandler +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 +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.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Switch +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.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.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.models.MemberTransferConfigEnvelope +import de.tt_tagebuch.shared.api.models.PredefinedActivityDto +import de.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody +import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody +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 +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +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, + MemberTransfer, +} + +@Composable +internal fun ClubStammdatenFlowScreen( + destination: ClubStammdatenSection, + dependencies: AppDependencies, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + when (destination) { + ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack) + ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack) + ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack) + } +} + +@Composable +private fun StammdatenTopBar(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)) +} + +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 + 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 list by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var refresh by remember { mutableStateOf(0) } + var editorTarget by remember { mutableStateOf(null) } + var creatingNew by remember { mutableStateOf(false) } + + LaunchedEffect(clubId, refresh) { + loading = true + error = null + list = runCatching { dependencies.predefinedActivitiesApi.list(scope = null) } + .onFailure { error = it.message } + .getOrDefault(emptyList()) + loading = false + } + + Column(modifier = Modifier.fillMaxSize().padding(StammdatenPad)) { + StammdatenTopBar(tr("mobile.predefinedActivities", "Standard-Aktivitäten"), onBack) + if (perms?.canReadPredefinedActivities() != true) { + Text(tr("mobile.noAccess", "Keine Berechtigung.")) + return@Column + } + TextButton( + onClick = { dependencies.openBackendPath("/predefined-activities") }, + modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin), + ) { Text(tr("mobile.openPredefinedWeb", "Volle Verwaltung (Web)")) } + if (perms.canWritePredefinedActivities()) { + Button( + onClick = { creatingNew = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .heightIn(min = StammdatenTouchMin), + ) { Text(tr("mobile.newPredefined", "Neue Aktivität")) } + } + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + error != null -> Text(error!!, color = MaterialTheme.colors.error) + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(list, key = { it.id }) { a -> + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + TextButton( + onClick = { editorTarget = a }, + enabled = perms.canWritePredefinedActivities(), + modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin), + ) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(a.displayLabel(), fontWeight = FontWeight.SemiBold) + a.code?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.caption) } + } + } + } + } + } + } + } + } + + if (editorTarget != null || creatingNew) { + PredefinedActivityEditorDialog( + initial = editorTarget, + isNew = creatingNew, + canSave = perms?.canWritePredefinedActivities() == true, + onDismiss = { editorTarget = null; creatingNew = false }, + onSaved = { + editorTarget = null + creatingNew = false + refresh++ + }, + resolve = { k, fb -> tr(k, fb) }, + scope = scope, + api = dependencies.predefinedActivitiesApi, + ) + } +} + +@Composable +@Composable +private fun PredefinedActivityEditorDialog( + initial: PredefinedActivityDto?, + isNew: Boolean, + canSave: Boolean, + onDismiss: () -> Unit, + onSaved: () -> Unit, + resolve: (String, String) -> String, + scope: kotlinx.coroutines.CoroutineScope, + api: de.tt_tagebuch.shared.api.PredefinedActivitiesApi, +) { + var name by remember(initial?.id, isNew) { mutableStateOf(initial?.name.orEmpty()) } + var code by remember(initial?.id, isNew) { mutableStateOf(initial?.code.orEmpty()) } + var description by remember(initial?.id, isNew) { mutableStateOf(initial?.description.orEmpty()) } + var durationText by remember(initial?.id, isNew) { mutableStateOf(initial?.durationText.orEmpty()) } + var duration by remember(initial?.id, isNew) { mutableStateOf(initial?.duration?.toString().orEmpty()) } + var exclude by remember(initial?.id, isNew) { mutableStateOf(initial?.excludeFromStats == true) } + var err by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (isNew) resolve("mobile.newPredefined", "Neue Aktivität") else resolve("mobile.editPredefined", "Bearbeiten")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + err?.let { Text(it, color = MaterialTheme.colors.error) } + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(resolve("tournaments.name", "Name")) }) + OutlinedTextField(value = code, onValueChange = { code = it }, label = { Text("Code") }) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(resolve("mobile.description", "Beschreibung")) }, + minLines = 2, + ) + OutlinedTextField(value = durationText, onValueChange = { durationText = it }, label = { Text("Dauer (Text)") }) + OutlinedTextField(value = duration, onValueChange = { duration = it.filter { ch -> ch.isDigit() } }, label = { Text("Dauer (Min)") }) + RowSwitch(resolve("mobile.excludeFromStats", "Von Statistik ausschließen"), exclude) { exclude = it } + } + }, + confirmButton = { + TextButton( + enabled = canSave && !busy && name.isNotBlank(), + onClick = { + scope.launch { + busy = true + err = null + val d = duration.toIntOrNull() + val body = PredefinedActivityUpsertBody( + name = name.trim(), + code = code.trim().ifBlank { null }, + description = description.trim().ifBlank { null }, + durationText = durationText.trim().ifBlank { null }, + duration = d, + excludeFromStats = exclude, + ) + runCatching { + if (isNew) { + api.create(body) + } else { + api.update(initial!!.id, body) + } + onSaved() + }.onFailure { err = it.message } + busy = false + } + }, + ) { Text(resolve("common.save", "Speichern")) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(resolve("common.cancel", "Abbrechen")) } + }, + ) +} + +@Composable +private fun MobileMemberTransferScreen(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 loading by remember { mutableStateOf(true) } + var server by remember { mutableStateOf("") } + var transferEndpoint by remember { mutableStateOf("") } + var transferMethod by remember { mutableStateOf("POST") } + var transferTemplate by remember { mutableStateOf("") } + var useBulk by remember { mutableStateOf(false) } + var bulkWrap by remember { mutableStateOf("") } + var loginUser by remember { mutableStateOf("") } + var loginPass by remember { mutableStateOf("") } + var hasConfig by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + + fun applyFromApi(env: MemberTransferConfigEnvelope?) { + val cfg = env?.config + if (cfg != null) { + hasConfig = true + server = cfg.server.orEmpty() + transferEndpoint = cfg.transferEndpoint.orEmpty() + transferMethod = cfg.transferMethod ?: "POST" + transferTemplate = cfg.transferTemplate.orEmpty() + useBulk = cfg.useBulkMode == true + bulkWrap = cfg.bulkWrapperTemplate.orEmpty() + loginUser = cfg.loginCredentials?.username.orEmpty() + loginPass = "" + } else { + hasConfig = false + server = "" + transferEndpoint = "" + transferMethod = "POST" + transferTemplate = "" + useBulk = false + bulkWrap = "" + loginUser = "" + loginPass = "" + } + } + + LaunchedEffect(clubId) { + loading = true + error = null + val env = runCatching { dependencies.memberTransferConfigApi.get(clubId) }.getOrNull() + if (env != null && !env.success) { + applyFromApi(null) + } else { + applyFromApi(env) + } + loading = false + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(StammdatenPad), + ) { + StammdatenTopBar(tr("mobile.memberTransfer", "Mitgliedstransfer"), onBack) + if (perms?.canReadMembers() != true) { + Text(tr("mobile.noAccess", "Keine Berechtigung.")) + return@Column + } + TextButton( + onClick = { dependencies.openBackendPath("/member-transfer-settings") }, + modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin), + ) { Text(tr("mobile.openMemberTransferWeb", "Erweiterte Einstellungen (Web)")) } + if (loading) { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + return@Column + } + Text( + if (hasConfig) tr("mobile.memberTransferConfigured", "Konfiguration vorhanden") else tr("mobile.memberTransferNotConfigured", "Nicht konfiguriert"), + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp), + ) + error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) } + + if (perms.canWriteMembers()) { + OutlinedTextField( + value = server, + onValueChange = { server = it }, + label = { Text("Server-URL") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = transferEndpoint, + onValueChange = { transferEndpoint = it }, + label = { Text("Transfer-Endpoint") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = transferMethod, + onValueChange = { transferMethod = it }, + label = { Text("HTTP-Methode") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = transferTemplate, + onValueChange = { transferTemplate = it }, + label = { Text("Transfer-Template") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + minLines = 4, + ) + RowSwitch("Bulk-Modus", useBulk) { useBulk = it } + if (useBulk) { + OutlinedTextField( + value = bulkWrap, + onValueChange = { bulkWrap = it }, + label = { Text("Bulk-Wrapper") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + minLines = 2, + ) + } + OutlinedTextField( + value = loginUser, + onValueChange = { loginUser = it }, + label = { Text("Login Benutzername") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = loginPass, + onValueChange = { loginPass = it }, + label = { Text("Login Passwort (nur bei Änderung)") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + scope.launch { + busy = true + error = null + val cred = if (loginUser.isNotBlank() || loginPass.isNotBlank()) { + buildJsonObject { + if (loginUser.isNotBlank()) put("username", JsonPrimitive(loginUser)) + if (loginPass.isNotBlank()) put("password", JsonPrimitive(loginPass)) + } + } else { + null + } + runCatching { + val body = MemberTransferConfigSaveBody( + server = server.trim(), + loginEndpoint = null, + transferEndpoint = transferEndpoint.trim(), + transferMethod = transferMethod.trim().ifBlank { "POST" }, + transferTemplate = transferTemplate, + useBulkMode = useBulk, + bulkWrapperTemplate = bulkWrap.ifBlank { null }, + loginCredentials = cred, + ) + val r = dependencies.memberTransferConfigApi.save(clubId, body) + if (!r.success) error = r.error ?: r.message ?: "Fehler" + else { + loginPass = "" + applyFromApi(r) + } + }.onFailure { error = it.message } + busy = false + } + }, + enabled = !busy && server.isNotBlank() && transferEndpoint.isNotBlank() && transferTemplate.isNotBlank(), + modifier = Modifier.weight(1f).heightIn(min = StammdatenTouchMin), + ) { Text(tr("common.save", "Speichern")) } + if (hasConfig) { + OutlinedButton( + onClick = { + scope.launch { + busy = true + error = null + runCatching { + val r = dependencies.memberTransferConfigApi.delete(clubId) + if (r.success) applyFromApi(null) + else error = r.error ?: r.message + }.onFailure { error = it.message } + busy = false + } + }, + enabled = !busy, + modifier = Modifier.heightIn(min = StammdatenTouchMin), + ) { Text(tr("common.delete", "Löschen")) } + } + } + } + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt index aa564dcc..c4d63d3d 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt @@ -260,7 +260,6 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { } } -@Composable @Composable private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) { TextButton( diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubsApi.kt index 36299614..a7f6ecca 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubsApi.kt @@ -2,9 +2,11 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient import de.tt_tagebuch.shared.api.models.Club +import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody import io.ktor.client.call.body 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 kotlinx.serialization.Serializable @@ -28,6 +30,12 @@ class ClubsApi( return client.http.get("/api/clubs/$clubId").body() } + suspend fun updateClubSettings(clubId: Int, body: UpdateClubSettingsBody) { + client.http.put("/api/clubs/$clubId/settings") { + setBody(body) + } + } + suspend fun requestAccess(clubId: Int) { client.http.get("/api/clubs/request/$clubId") } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberTransferConfigApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberTransferConfigApi.kt new file mode 100644 index 00000000..d73b8bfc --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberTransferConfigApi.kt @@ -0,0 +1,33 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.ApiException +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope +import de.tt_tagebuch.shared.api.models.MemberTransferConfigSaveBody +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.setBody + +class MemberTransferConfigApi( + private val client: AuthedHttpClient, +) { + suspend fun get(clubId: Int): MemberTransferConfigEnvelope? { + return try { + client.http.get("/api/member-transfer-config/$clubId").body() + } catch (e: ApiException) { + if (e.statusCode == 404) null else throw e + } + } + + suspend fun save(clubId: Int, body: MemberTransferConfigSaveBody): MemberTransferConfigEnvelope { + return client.http.post("/api/member-transfer-config/$clubId") { + setBody(body) + }.body() + } + + suspend fun delete(clubId: Int): MemberTransferConfigEnvelope { + return client.http.delete("/api/member-transfer-config/$clubId").body() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PredefinedActivitiesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PredefinedActivitiesApi.kt index d33549ec..a7d19d2d 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PredefinedActivitiesApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PredefinedActivitiesApi.kt @@ -2,9 +2,13 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient import de.tt_tagebuch.shared.api.models.PredefinedActivityDto +import de.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody class PredefinedActivitiesApi( private val client: AuthedHttpClient, @@ -25,4 +29,16 @@ class PredefinedActivitiesApi( suspend fun getById(id: Int): PredefinedActivityDto { return client.http.get("/api/predefined-activities/$id").body() } + + suspend fun create(body: PredefinedActivityUpsertBody): PredefinedActivityDto { + return client.http.post("/api/predefined-activities") { + setBody(body) + }.body() + } + + suspend fun update(id: Int, body: PredefinedActivityUpsertBody): PredefinedActivityDto { + return client.http.put("/api/predefined-activities/$id") { + setBody(body) + }.body() + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Club.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Club.kt index d1ffde8a..d38a9364 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Club.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Club.kt @@ -2,6 +2,26 @@ package de.tt_tagebuch.shared.api.models import kotlinx.serialization.Serializable +@Serializable +data class MemberDataQualityRequirements( + val requireStreet: Boolean = true, + val requirePostalCode: Boolean = true, + val requireCity: Boolean = true, + val requirePhone: Boolean = true, + val requireEmail: Boolean = true, +) + +@Serializable +data class UpdateClubSettingsBody( + val greetingText: String? = null, + val associationMemberNumber: String? = null, + val countryCode: String? = null, + val stateCode: String? = null, + val myTischtennisFedNickname: String? = null, + val autoFetchRankings: Boolean? = null, + val memberDataQualityRequirements: MemberDataQualityRequirements? = null, +) + @Serializable data class Club( val id: Int, @@ -10,5 +30,8 @@ data class Club( val associationMemberNumber: String? = null, val myTischtennisFedNickname: String? = null, val autoFetchRankings: Boolean? = null, + val countryCode: String? = null, + val stateCode: String? = null, + val memberDataQualityRequirements: MemberDataQualityRequirements? = null, ) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt index ddc8d394..7b45819b 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt @@ -81,3 +81,23 @@ fun UserClubPermissions.canWriteTournaments(): Boolean { if (isOwner) return true return permissions.boolAt("tournaments", "write") } + +fun UserClubPermissions.canReadClubSettings(): Boolean { + if (isOwner) return true + return permissions.boolAt("settings", "read") +} + +fun UserClubPermissions.canWriteClubSettings(): Boolean { + if (isOwner) return true + return permissions.boolAt("settings", "write") +} + +fun UserClubPermissions.canReadPredefinedActivities(): Boolean { + if (isOwner) return true + return permissions.boolAt("predefined_activities", "read") +} + +fun UserClubPermissions.canWritePredefinedActivities(): Boolean { + if (isOwner) return true + return permissions.boolAt("predefined_activities", "write") +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt new file mode 100644 index 00000000..0d99b35a --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt @@ -0,0 +1,47 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class MemberTransferLoginCredentialsDto( + val username: String? = null, +) + +@Serializable +data class MemberTransferConfigDto( + val id: Int? = null, + val clubId: Int? = null, + val server: String? = null, + val loginEndpoint: String? = null, + val loginFormat: String? = null, + val loginCredentials: MemberTransferLoginCredentialsDto? = null, + val transferEndpoint: String? = null, + val transferMethod: String? = null, + val transferFormat: String? = null, + val transferTemplate: String? = null, + val useBulkMode: Boolean? = null, + val bulkWrapperTemplate: String? = null, +) + +@Serializable +data class MemberTransferConfigEnvelope( + val success: Boolean = false, + val config: MemberTransferConfigDto? = null, + val message: String? = null, + val error: String? = null, +) + +@Serializable +data class MemberTransferConfigSaveBody( + val server: String, + val loginEndpoint: String? = null, + val loginFormat: String = "json", + val loginCredentials: JsonObject? = null, + val transferEndpoint: String, + val transferMethod: String = "POST", + val transferFormat: String = "json", + val transferTemplate: String, + val useBulkMode: Boolean = false, + val bulkWrapperTemplate: String? = null, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/PredefinedActivityDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/PredefinedActivityDtos.kt index 5fd039d3..07819a9d 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/PredefinedActivityDtos.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/PredefinedActivityDtos.kt @@ -13,6 +13,18 @@ data class PredefinedActivityDto( val excludeFromStats: Boolean? = null, ) +@Serializable +data class PredefinedActivityUpsertBody( + val name: String? = null, + val code: String? = null, + val description: String? = null, + val duration: Int? = null, + val durationText: String? = null, + val imageLink: String? = null, + val drawingData: String? = null, + val excludeFromStats: Boolean? = null, +) + fun PredefinedActivityDto.displayLabel(): String { val n = name?.trim().orEmpty() if (n.isNotEmpty()) return n diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubManager.kt index 87be2264..6d4f5fe4 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubManager.kt @@ -3,6 +3,7 @@ package de.tt_tagebuch.shared.state import de.tt_tagebuch.shared.api.ClubsApi import de.tt_tagebuch.shared.api.PermissionsApi import de.tt_tagebuch.shared.api.models.Club +import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,6 +51,10 @@ class ClubManager( return clubsApi.getClub(clubId) } + suspend fun updateClubSettings(clubId: Int, body: UpdateClubSettingsBody) { + clubsApi.updateClubSettings(clubId, body) + } + suspend fun selectClub(clubId: Int) { _state.value = _state.value.copy(isLoading = true, error = null) try {