feat(Tournament): add official tournament participation feature
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:52:54 +02:00
parent bea5facb7d
commit 57468f1efb
15 changed files with 931 additions and 11 deletions

View File

@@ -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({

View File

@@ -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; }

View File

@@ -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
---

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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<GermanStateOption> = 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<Club?>(null) }
var loadError by remember { mutableStateOf<String?>(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<List<PredefinedActivityDto>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var refresh by remember { mutableStateOf(0) }
var editorTarget by remember { mutableStateOf<PredefinedActivityDto?>(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<String?>(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<String?>(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")) }
}
}
}
}
}

View File

@@ -260,7 +260,6 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
}
}
@Composable
@Composable
private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
TextButton(

View File

@@ -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")
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)

View File

@@ -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")
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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 {